From 7dffc4c64a9de48e0d6f3f59abdcc57f1634c019 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:27:59 +0100 Subject: [PATCH 001/164] feat: add fuzzy match distance setting to Intune template based on Levenshtein distance --- src/data/standards.json | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index fe08f2fcbb45..cddee8945751 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -4779,7 +4779,7 @@ } ] }, - { + { "type": "switch", "name": "standards.TeamsGlobalMeetingPolicy.AllowPSTNUsersToBypassLobby", "label": "Allow dial-in users to bypass lobby" @@ -5106,10 +5106,7 @@ "condition": { "field": "standards.TeamsFederationConfiguration.DomainControl.value", "compareType": "isOneOf", - "compareValue": [ - "AllowSpecificExternal", - "BlockSpecificExternal" - ] + "compareValue": ["AllowSpecificExternal", "BlockSpecificExternal"] } } ], @@ -5530,6 +5527,13 @@ "value": "exclude" } ] + }, + { + "type": "number", + "required": false, + "name": "levenshteinDistance", + "label": "Fuzzy Match Distance (0 = exact name match only, higher values allow replacing policies with similar names based on Levenshtein distance)", + "defaultValue": 0 } ] }, From 4fc1b57b65a49f832db2ded4e15563a21ac1ebfa Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:41:42 +0100 Subject: [PATCH 002/164] feat: add warningMessage support standards --- .../CippStandards/CippStandardAccordion.jsx | 49 ++++++++++++++++--- src/data/standards.json | 7 ++- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index 1a2811d462c3..31a49987ceaa 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useMemo } from "react"; import { Card, Stack, + Alert, Avatar, Box, Typography, @@ -53,8 +54,9 @@ const getAvailableActions = (disabledFeatures) => { return allActions.filter((action) => !disabledFeatures?.[action.value.toLowerCase()]); }; -const CippAddedComponent = React.memo(({ standardName, component, formControl }) => { +const CippAddedComponent = React.memo(({ standardName, component, formControl, currentValue }) => { const updatedComponent = { ...component }; + const fieldName = `${standardName}.${updatedComponent.name}`; if (component.type === "AdminRolesMultiSelect") { updatedComponent.type = "autoComplete"; @@ -73,15 +75,30 @@ const CippAddedComponent = React.memo(({ standardName, component, formControl }) updatedComponent.type = component.type; } + const warningThreshold = Number(updatedComponent.warningThreshold); + const numericValue = Number(currentValue); + const showThresholdWarning = + Number.isFinite(warningThreshold) && + !Number.isNaN(numericValue) && + `${currentValue}`.trim() !== "" && + numericValue > warningThreshold; + + const warningMessage = + updatedComponent.warningMessage || + `Values above ${warningThreshold} can match unrelated policies. Use with caution.`; + return ( - + + + {showThresholdWarning && {warningMessage}} + ); }); @@ -937,6 +954,10 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} + currentValue={_.get( + watchedValues, + `${standardName}.${component.name}`, + )} /> ) : ( @@ -945,6 +966,10 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} + currentValue={_.get( + watchedValues, + `${standardName}.${component.name}`, + )} /> ), )} @@ -995,6 +1020,10 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} + currentValue={_.get( + watchedValues, + `${standardName}.${component.name}`, + )} /> ) : ( @@ -1003,6 +1032,10 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} + currentValue={_.get( + watchedValues, + `${standardName}.${component.name}`, + )} /> ), )} diff --git a/src/data/standards.json b/src/data/standards.json index cddee8945751..86a6cf5964c5 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -5533,7 +5533,12 @@ "required": false, "name": "levenshteinDistance", "label": "Fuzzy Match Distance (0 = exact name match only, higher values allow replacing policies with similar names based on Levenshtein distance)", - "defaultValue": 0 + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" } + }, + "warningThreshold": 5, + "warningMessage": "Warning: values above 5 can match unrelated policies. Use with caution." } ] }, From 88bc10a527efe58a986bf96c551c07c74c3f6ab5 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Thu, 2 Apr 2026 19:03:48 +0100 Subject: [PATCH 003/164] Added UI Elements for adding conditional access policies to package tags Added UI Elements for adding conditional access policies to package tags --- src/data/standards.json | 17 +++++++++ .../tenant/conditional/list-template/index.js | 35 +++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index f643617f475a..133db7df11bc 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -5669,6 +5669,23 @@ } } }, + { + "type": "autoComplete", + "multiple": false, + "required": false, + "creatable": false, + "name": "TemplateList-Tags", + "label": "Or select a package of CA Templates", + "api": { + "queryKey": "ListCATemplates-tag-autocomplete", + "url": "/api/ListCATemplates?mode=Tag", + "labelField": "label", + "valueField": "value", + "addedField": { + "templates": "templates" + } + } + }, { "name": "state", "label": "What state should we deploy this template in?", diff --git a/src/pages/tenant/conditional/list-template/index.js b/src/pages/tenant/conditional/list-template/index.js index 41221d021582..5f7daf3b2737 100644 --- a/src/pages/tenant/conditional/list-template/index.js +++ b/src/pages/tenant/conditional/list-template/index.js @@ -2,7 +2,7 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { Button, Box } from "@mui/material"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; -import { Delete, GitHub, Edit, RocketLaunch } from "@mui/icons-material"; +import { Delete, GitHub, Edit, RocketLaunch, LocalOffer, LocalOfferOutlined } from "@mui/icons-material"; import { ApiGetCall } from "../../../../api/ApiCall"; import { CippPolicyImportDrawer } from "../../../../components/CippComponents/CippPolicyImportDrawer.jsx"; import { CippCADeployDrawer } from "../../../../components/CippComponents/CippCADeployDrawer.jsx"; @@ -42,6 +42,37 @@ const Page = () => { icon: , color: "info", }, + { + label: "Add to package", + type: "POST", + url: "/api/ExecSetPackageTag", + data: { GUID: "GUID" }, + fields: [ + { + type: "textField", + name: "Package", + label: "Package Name", + required: true, + validators: { + required: { value: true, message: "Package name is required" }, + }, + }, + ], + confirmText: "Enter the package name to assign to the selected template(s).", + multiPost: true, + icon: , + color: "info", + }, + { + label: "Remove from package", + type: "POST", + url: "/api/ExecSetPackageTag", + data: { GUID: "GUID", Remove: true }, + confirmText: "Are you sure you want to remove the selected template(s) from their package?", + multiPost: true, + icon: , + color: "warning", + }, { label: "Save to GitHub", type: "POST", @@ -110,7 +141,7 @@ const Page = () => { queryKey="ListCATemplates-table" actions={actions} offCanvas={offCanvas} - simpleColumns={["displayName", "GUID"]} + simpleColumns={["displayName", "package", "GUID"]} cardButton={ - - } - apiUrl="/api/ListAssignmentFilters" - queryKey={`assignment-filters-${currentTenant}`} - actions={actions} - offCanvas={offCanvas} - simpleColumns={[ - "displayName", - "description", - "platform", - "assignmentFilterManagementType", - "rule", - ]} - /> + + )} + + + : } + label={useReportDB ? "Cached" : "Live"} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + , + ]; + + return ( + <> + + + {pageActions} + + } + apiUrl={`/api/ListAssignmentFilters${useReportDB ? "?UseReportDB=true" : ""}`} + queryKey={`assignment-filters-${currentTenant}-${useReportDB}`} + actions={actions} + offCanvas={offCanvas} + simpleColumns={simpleColumns} + /> + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result?.Metadata?.QueueId); + } + }, + }} + /> + ); }; diff --git a/src/pages/endpoint/MEM/devices/index.js b/src/pages/endpoint/MEM/devices/index.js index e8e34e9338d5..5f1d42f3254e 100644 --- a/src/pages/endpoint/MEM/devices/index.js +++ b/src/pages/endpoint/MEM/devices/index.js @@ -4,9 +4,14 @@ import { CippApiDialog } from "../../../../components/CippComponents/CippApiDial import { useSettings } from "../../../../hooks/use-settings"; import { useDialog } from "../../../../hooks/use-dialog.js"; import { EyeIcon } from "@heroicons/react/24/outline"; -import { Box, Button } from "@mui/material"; +import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; +import { Stack } from "@mui/system"; +import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; +import { useEffect, useState } from "react"; import { Sync, + CloudDone, + Bolt, RestartAlt, LocationOn, Password, @@ -25,7 +30,15 @@ import { const Page = () => { const pageTitle = "Devices"; const tenantFilter = useSettings().currentTenant; + const isAllTenants = tenantFilter === "AllTenants"; const depSyncDialog = useDialog(); + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + const [useReportDB, setUseReportDB] = useState(isAllTenants); + + useEffect(() => { + setUseReportDB(tenantFilter === "AllTenants"); + }, [tenantFilter]); const actions = [ { @@ -385,6 +398,8 @@ const Page = () => { }; const simpleColumns = [ + ...(useReportDB ? ["CacheTimestamp"] : []), + ...(useReportDB && isAllTenants ? ["Tenant"] : []), "deviceName", "userPrincipalName", "complianceState", @@ -398,6 +413,53 @@ const Page = () => { "joinType", ]; + const pageActions = [ + + {useReportDB && ( + <> + + + + )} + + + : } + label={useReportDB ? "Cached" : "Live"} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + , + ]; + return ( <> { apiUrl="/api/ListGraphRequest" apiData={{ Endpoint: "deviceManagement/managedDevices", + ...(useReportDB ? { UseReportDB: true } : {}), }} apiDataKey="Results" actions={actions} - queryKey={`MEMDevices-${tenantFilter}`} + queryKey={`MEMDevices-${tenantFilter}-${useReportDB}`} offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={ - + - + {pageActions} + } /> { confirmText: `Are you sure you want to sync Apple Device Enrollment Program (DEP) tokens? This will sync all DEP tokens for ${tenantFilter}. This may take several minutes to complete in the background, and can only be done every 15 minutes.`, }} /> + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result?.Metadata?.QueueId); + } + }, + }} + /> ); }; diff --git a/src/pages/endpoint/MEM/list-appprotection-policies/index.js b/src/pages/endpoint/MEM/list-appprotection-policies/index.js index bcf12e6e4efd..f20bee7ffcc2 100644 --- a/src/pages/endpoint/MEM/list-appprotection-policies/index.js +++ b/src/pages/endpoint/MEM/list-appprotection-policies/index.js @@ -1,14 +1,29 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog.jsx' import { PermissionButton } from '../../../../utils/permissions.js' import { CippPolicyDeployDrawer } from '../../../../components/CippComponents/CippPolicyDeployDrawer.jsx' import { useSettings } from '../../../../hooks/use-settings.js' import { useCippIntunePolicyActions } from '../../../../components/CippComponents/CippIntunePolicyActions.jsx' +import { Sync, CloudDone, Bolt } from '@mui/icons-material' +import { Button, Chip, SvgIcon, Tooltip } from '@mui/material' +import { Stack } from '@mui/system' +import { useDialog } from '../../../../hooks/use-dialog' +import { CippQueueTracker } from '../../../../components/CippTable/CippQueueTracker' +import { useEffect, useState } from 'react' const Page = () => { const pageTitle = 'App Protection & Configuration Policies' const cardButtonPermissions = ['Endpoint.MEM.ReadWrite'] const tenant = useSettings().currentTenant + const isAllTenants = tenant === 'AllTenants' + const syncDialog = useDialog() + const [syncQueueId, setSyncQueueId] = useState(null) + const [useReportDB, setUseReportDB] = useState(isAllTenants) + + useEffect(() => { + setUseReportDB(tenant === 'AllTenants') + }, [tenant]) const actions = useCippIntunePolicyActions(tenant, 'URLName', { templateData: { @@ -31,6 +46,8 @@ const Page = () => { } const simpleColumns = [ + ...(useReportDB ? ['CacheTimestamp'] : []), + ...(useReportDB && isAllTenants ? ['Tenant'] : []), 'displayName', 'PolicyTypeName', 'PolicyAssignment', @@ -38,21 +55,93 @@ const Page = () => { 'lastModifiedDateTime', ] + const pageActions = [ + + {useReportDB && ( + <> + + + + )} + + + : } + label={useReportDB ? 'Cached' : 'Live'} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + , + ] + return ( - - } - /> + <> + + + {pageActions} + + } + /> + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result?.Metadata?.QueueId) + } + }, + }} + /> + ) } diff --git a/src/pages/endpoint/MEM/list-compliance-policies/index.js b/src/pages/endpoint/MEM/list-compliance-policies/index.js index b3394023c492..0b8e395c3db0 100644 --- a/src/pages/endpoint/MEM/list-compliance-policies/index.js +++ b/src/pages/endpoint/MEM/list-compliance-policies/index.js @@ -1,14 +1,29 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; import { PermissionButton } from "../../../../utils/permissions.js"; import { CippPolicyDeployDrawer } from "../../../../components/CippComponents/CippPolicyDeployDrawer.jsx"; import { useSettings } from "../../../../hooks/use-settings.js"; import { useCippIntunePolicyActions } from "../../../../components/CippComponents/CippIntunePolicyActions.jsx"; +import { Sync, CloudDone, Bolt } from "@mui/icons-material"; +import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; +import { Stack } from "@mui/system"; +import { useDialog } from "../../../../hooks/use-dialog"; +import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; +import { useEffect, useState } from "react"; const Page = () => { const pageTitle = "Intune Compliance Policies"; const cardButtonPermissions = ["Endpoint.MEM.ReadWrite"]; const tenant = useSettings().currentTenant; + const isAllTenants = tenant === "AllTenants"; + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + const [useReportDB, setUseReportDB] = useState(isAllTenants); + + useEffect(() => { + setUseReportDB(tenant === "AllTenants"); + }, [tenant]); const actions = useCippIntunePolicyActions(tenant, "deviceCompliancePolicies", { templateData: { @@ -29,6 +44,8 @@ const Page = () => { }; const simpleColumns = [ + ...(useReportDB ? ["CacheTimestamp"] : []), + ...(useReportDB && isAllTenants ? ["Tenant"] : []), "displayName", "PolicyTypeName", "PolicyAssignment", @@ -37,21 +54,93 @@ const Page = () => { "lastModifiedDateTime", ]; + const pageActions = [ + + {useReportDB && ( + <> + + + + )} + + + : } + label={useReportDB ? "Cached" : "Live"} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + , + ]; + return ( - - } - /> + <> + + + {pageActions} + + } + /> + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result?.Metadata?.QueueId); + } + }, + }} + /> + ); }; diff --git a/src/pages/endpoint/MEM/list-policies/index.js b/src/pages/endpoint/MEM/list-policies/index.js index 1a78f45906cb..8c5d4782513c 100644 --- a/src/pages/endpoint/MEM/list-policies/index.js +++ b/src/pages/endpoint/MEM/list-policies/index.js @@ -4,8 +4,8 @@ import { PermissionButton } from '../../../../utils/permissions.js' import { CippPolicyDeployDrawer } from '../../../../components/CippComponents/CippPolicyDeployDrawer.jsx' import { useSettings } from '../../../../hooks/use-settings.js' import { useCippIntunePolicyActions } from '../../../../components/CippComponents/CippIntunePolicyActions.jsx' -import { Sync, Info, CloudDone, Bolt } from '@mui/icons-material' -import { Button, SvgIcon, IconButton, Tooltip, Chip } from '@mui/material' +import { Sync, CloudDone, Bolt } from '@mui/icons-material' +import { Button, SvgIcon, Tooltip, Chip } from '@mui/material' import { Stack } from '@mui/system' import { useDialog } from '../../../../hooks/use-dialog' import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog' @@ -45,7 +45,8 @@ const Page = () => { } const simpleColumns = [ - ...(useReportDB ? ['Tenant', 'CacheTimestamp'] : []), + ...(useReportDB ? ['CacheTimestamp'] : []), + ...(useReportDB && isAllTenants ? ['Tenant'] : []), 'displayName', 'PolicyTypeName', 'PolicyAssignment', @@ -81,8 +82,8 @@ const Page = () => { isAllTenants ? 'AllTenants always uses cached data' : useReportDB - ? 'Showing cached data from the Reporting Database — click to switch to live' - : 'Showing live data — click to switch to cache' + ? 'Showing cached data from the Reporting Database - click to switch to live' + : 'Showing live data - click to switch to cache' } > diff --git a/src/pages/endpoint/MEM/list-scripts/index.jsx b/src/pages/endpoint/MEM/list-scripts/index.jsx index 91eb0bf675a6..11b480fae484 100644 --- a/src/pages/endpoint/MEM/list-scripts/index.jsx +++ b/src/pages/endpoint/MEM/list-scripts/index.jsx @@ -1,5 +1,6 @@ import { Layout as DashboardLayout } from "../../../../layouts/index"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage"; +import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog"; import { TrashIcon, PencilIcon, @@ -16,14 +17,19 @@ import { IconButton, CircularProgress, DialogActions, + Chip, + SvgIcon, + Tooltip, } from "@mui/material"; import { CippCodeBlock } from "../../../../components/CippComponents/CippCodeBlock"; import { useState, useEffect, useMemo } from "react"; import { useDispatch } from "react-redux"; -import { Close, Save, LaptopChromebook } from "@mui/icons-material"; +import { Close, Save, LaptopChromebook, Sync, CloudDone, Bolt } from "@mui/icons-material"; import { useSettings } from "../../../../hooks/use-settings"; +import { useDialog } from "../../../../hooks/use-dialog"; import { Stack } from "@mui/system"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; const assignmentModeOptions = [ { label: "Replace existing assignments", value: "replace" }, @@ -39,6 +45,17 @@ const Page = () => { const [codeContentChanged, setCodeContentChanged] = useState(false); const [warnOpen, setWarnOpen] = useState(false); const [currentScript, setCurrentScript] = useState(null); + const [scriptTenant, setScriptTenant] = useState(null); + + const tenantFilter = useSettings().currentTenant; + const isAllTenants = tenantFilter === "AllTenants"; + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + const [useReportDB, setUseReportDB] = useState(isAllTenants); + + useEffect(() => { + setUseReportDB(tenantFilter === "AllTenants"); + }, [tenantFilter]); const dispatch = useDispatch(); @@ -48,17 +65,16 @@ const Page = () => { : "powershell"; }, [currentScript?.scriptType]); - const tenantFilter = useSettings().currentTenant; const { isLoading: scriptIsLoading, isRefetching: scriptIsFetching, refetch: scriptRefetch, data, } = useQuery({ - queryKey: ["script", { scriptId }], + queryKey: ["script", { scriptId, scriptTenant }], queryFn: async () => { const response = await fetch( - `/api/EditIntuneScript?TenantFilter=${tenantFilter}&ScriptId=${scriptId}` + `/api/EditIntuneScript?TenantFilter=${scriptTenant || tenantFilter}&ScriptId=${scriptId}` ); return response.json(); }, @@ -79,6 +95,7 @@ const Page = () => { const handleScriptEdit = async (row, action) => { setScriptId(row.id); + setScriptTenant(row?.Tenant || tenantFilter); setCodeOpen(!codeOpen); }; @@ -94,6 +111,7 @@ const Page = () => { setCodeOpen(!codeOpen); setCodeContentChanged(false); setScriptId(null); + setScriptTenant(null); setCodeContent(""); } }; @@ -114,7 +132,7 @@ const Page = () => { scriptType, } = currentScript; const patchData = { - TenantFilter: tenantFilter, + TenantFilter: scriptTenant || tenantFilter, ScriptId: id, ScriptType: scriptType, IntuneScript: JSON.stringify({ @@ -197,7 +215,7 @@ const Page = () => { ], confirmText: 'Are you sure you want to assign "[displayName]" to all users?', customDataformatter: (row, action, formData) => ({ - tenantFilter: tenantFilter, + tenantFilter: tenantFilter === "AllTenants" && row?.Tenant ? row.Tenant : tenantFilter, ID: row?.id, Type: getScriptEndpoint(row?.scriptType), AssignTo: "allLicensedUsers", @@ -223,7 +241,7 @@ const Page = () => { ], confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', customDataformatter: (row, action, formData) => ({ - tenantFilter: tenantFilter, + tenantFilter: tenantFilter === "AllTenants" && row?.Tenant ? row.Tenant : tenantFilter, ID: row?.id, Type: getScriptEndpoint(row?.scriptType), AssignTo: "AllDevices", @@ -249,7 +267,7 @@ const Page = () => { ], confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', customDataformatter: (row, action, formData) => ({ - tenantFilter: tenantFilter, + tenantFilter: tenantFilter === "AllTenants" && row?.Tenant ? row.Tenant : tenantFilter, ID: row?.id, Type: getScriptEndpoint(row?.scriptType), AssignTo: "AllDevicesAndUsers", @@ -305,7 +323,7 @@ const Page = () => { customDataformatter: (row, action, formData) => { const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; return { - tenantFilter: tenantFilter, + tenantFilter: tenantFilter === "AllTenants" && row?.Tenant ? row.Tenant : tenantFilter, ID: row?.id, Type: getScriptEndpoint(row?.scriptType), GroupIds: selectedGroups.map((group) => group.value).filter(Boolean), @@ -354,6 +372,8 @@ const Page = () => { }; const simpleColumns = [ + ...(useReportDB ? ["CacheTimestamp"] : []), + ...(useReportDB && isAllTenants ? ["Tenant"] : []), "scriptType", "displayName", "ScriptAssignment", @@ -363,14 +383,63 @@ const Page = () => { "lastModifiedDateTime", ]; + const pageActions = [ + + {useReportDB && ( + <> + + + + )} + + + : } + label={useReportDB ? "Cached" : "Live"} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + , + ]; + return ( <> @@ -427,6 +496,7 @@ const Page = () => { setWarnOpen(false); setCodeContent(""); setScriptId(null); + setScriptTenant(null); setCodeContentChanged(false); }} > @@ -434,9 +504,28 @@ const Page = () => { + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result?.Metadata?.QueueId); + } + }, + }} + /> ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/endpoint/MEM/reusable-settings/index.js b/src/pages/endpoint/MEM/reusable-settings/index.js index c301082ea1f0..0c3837e51a0c 100644 --- a/src/pages/endpoint/MEM/reusable-settings/index.js +++ b/src/pages/endpoint/MEM/reusable-settings/index.js @@ -1,18 +1,34 @@ -import { Book, DeleteForever } from "@mui/icons-material"; +import { Book, DeleteForever, Sync, CloudDone, Bolt } from "@mui/icons-material"; import { CippReusableSettingsDeployDrawer } from "../../../../components/CippComponents/CippReusableSettingsDeployDrawer.jsx"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { useSettings } from "../../../../hooks/use-settings"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; +import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; +import { Stack } from "@mui/system"; +import { useDialog } from "../../../../hooks/use-dialog"; +import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; +import { useEffect, useState } from "react"; const Page = () => { const { currentTenant } = useSettings(); const pageTitle = "Reusable Settings"; + const isAllTenants = currentTenant === "AllTenants"; + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + const [useReportDB, setUseReportDB] = useState(isAllTenants); + + useEffect(() => { + setUseReportDB(currentTenant === "AllTenants"); + }, [currentTenant]); const actions = [ { label: "Edit Reusable Setting", - link: `/endpoint/MEM/reusable-settings/edit?id=[id]&tenant=${currentTenant}&tenantFilter=${currentTenant}`, + link: isAllTenants + ? "/endpoint/MEM/reusable-settings/edit?id=[id]&tenant=[Tenant]&tenantFilter=[Tenant]" + : `/endpoint/MEM/reusable-settings/edit?id=[id]&tenant=${currentTenant}&tenantFilter=${currentTenant}`, }, { label: "Delete Reusable Setting", @@ -47,18 +63,98 @@ const Page = () => { size: "lg", }; + const simpleColumns = [ + ...(useReportDB ? ["CacheTimestamp"] : []), + ...(useReportDB && isAllTenants ? ["Tenant"] : []), + "displayName", + "description", + "id", + "version", + ]; + + const pageActions = [ + + {useReportDB && ( + <> + + + + )} + + + : } + label={useReportDB ? "Cached" : "Live"} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + , + ]; + return ( - - } - apiUrl="/api/ListIntuneReusableSettings" - queryKey={`ListIntuneReusableSettings-${currentTenant}`} - actions={actions} - offCanvas={offCanvas} - simpleColumns={["displayName", "description", "id", "version"]} - /> + <> + + + {pageActions} + + } + apiUrl={`/api/ListIntuneReusableSettings${useReportDB ? "?UseReportDB=true" : ""}`} + queryKey={`ListIntuneReusableSettings-${currentTenant}-${useReportDB}`} + actions={actions} + offCanvas={offCanvas} + simpleColumns={simpleColumns} + /> + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result?.Metadata?.QueueId); + } + }, + }} + /> + ); }; diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 41671638cfce..4b8b60239a47 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -2,11 +2,14 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from "@heroicons/react/24/outline"; -import { LaptopMac, Sync, BookmarkAdd } from "@mui/icons-material"; +import { LaptopMac, Sync, BookmarkAdd, CloudDone, Bolt } from "@mui/icons-material"; import { CippApplicationDeployDrawer } from "../../../../components/CippComponents/CippApplicationDeployDrawer"; -import { Button, Box } from "@mui/material"; +import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; +import { Stack } from "@mui/system"; import { useSettings } from "../../../../hooks/use-settings.js"; import { useDialog } from "../../../../hooks/use-dialog.js"; +import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; +import { useEffect, useState } from "react"; const assignmentIntentOptions = [ { label: "Required", value: "Required" }, @@ -44,8 +47,16 @@ const mapOdataToAppType = (odataType) => { const Page = () => { const pageTitle = "Applications"; - const syncDialog = useDialog(); + const vppSyncDialog = useDialog(); + const cacheSyncDialog = useDialog(); const tenant = useSettings().currentTenant; + const isAllTenants = tenant === "AllTenants"; + const [syncQueueId, setSyncQueueId] = useState(null); + const [useReportDB, setUseReportDB] = useState(isAllTenants); + + useEffect(() => { + setUseReportDB(tenant === "AllTenants"); + }, [tenant]); const getAssignmentFilterFields = () => [ { @@ -291,6 +302,8 @@ const Page = () => { }; const simpleColumns = [ + ...(useReportDB ? ["CacheTimestamp"] : []), + ...(useReportDB && isAllTenants ? ["Tenant"] : []), "displayName", "AppAssignment", "AppExclude", @@ -299,26 +312,75 @@ const Page = () => { "createdDateTime", ]; + const pageActions = [ + + {useReportDB && ( + <> + + + + )} + + + : } + label={useReportDB ? "Cached" : "Live"} + color="primary" + size="small" + onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} + clickable={!isAllTenants} + disabled={isAllTenants} + variant="outlined" + /> + + + , + ]; + return ( <> + - - + {pageActions} + } /> { confirmText: `Are you sure you want to sync Apple Volume Purchase Program (VPP) tokens? This will sync all VPP tokens for ${tenant}.`, }} /> + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result?.Metadata?.QueueId); + } + }, + }} + /> ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; From 1707506b0a39fbf7c0154b9608b9d90a69c19331 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:22:00 +0200 Subject: [PATCH 012/164] feat: integrate useCippReportDB for data handling - Replaced local state management for report database usage with useCippReportDB hook across multiple components. - Simplified API calls and state management for caching and syncing data. - Removed redundant code related to tenant checks and sync dialogs. --- .../CippComponents/CippReportDBControls.jsx | 30 ++++-- .../endpoint/MEM/assignment-filters/index.js | 102 +++--------------- src/pages/endpoint/MEM/devices/index.js | 86 +-------------- .../MEM/list-appprotection-policies/index.js | 98 +++-------------- .../MEM/list-compliance-policies/index.js | 98 +++-------------- src/pages/endpoint/MEM/list-scripts/index.jsx | 101 +++-------------- .../endpoint/MEM/reusable-settings/index.js | 100 +++-------------- src/pages/endpoint/applications/list/index.js | 98 +++-------------- 8 files changed, 112 insertions(+), 601 deletions(-) diff --git a/src/components/CippComponents/CippReportDBControls.jsx b/src/components/CippComponents/CippReportDBControls.jsx index 063fd386bb5b..889185e5bfad 100644 --- a/src/components/CippComponents/CippReportDBControls.jsx +++ b/src/components/CippComponents/CippReportDBControls.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useMemo, useCallback } from "react"; import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; import { Stack } from "@mui/system"; import { Sync, CloudDone, Bolt } from "@mui/icons-material"; @@ -53,16 +53,24 @@ export function useCippReportDB(config) { const isAllTenants = currentTenant === "AllTenants"; const dialog = useDialog(); const [syncQueueId, setSyncQueueId] = useState(null); - const [useReportDB, setUseReportDB] = useState(defaultCached); + const [cacheOverride, setCacheOverride] = useState({ tenant: null, value: null }); + const useReportDB = isAllTenants + ? true + : cacheOverride.tenant === currentTenant + ? cacheOverride.value + : defaultCached; + const setUseReportDB = useCallback( + (valueOrUpdater) => { + setCacheOverride((prev) => { + const previousValue = prev.tenant === currentTenant ? prev.value : defaultCached; + const nextValue = + typeof valueOrUpdater === "function" ? valueOrUpdater(previousValue) : valueOrUpdater; - // Reset to default whenever tenant changes; AllTenants always forces cached - useEffect(() => { - if (isAllTenants) { - setUseReportDB(true); - } else { - setUseReportDB(defaultCached); - } - }, [currentTenant, isAllTenants, defaultCached]); + return { tenant: currentTenant, value: nextValue }; + }); + }, + [currentTenant, defaultCached], + ); // Whether the toggle is actually clickable const canToggle = allowToggle && !isAllTenants; @@ -173,7 +181,7 @@ export function useCippReportDB(config) { relatedQueryKeys: [`${queryKey}-${currentTenant}-true`], data: { Name: cacheName, - Types: "None", + ...(cacheName === "Mailboxes" ? { Types: "None" } : {}), ...(syncData || {}), }, onSuccess: handleSyncSuccess, diff --git a/src/pages/endpoint/MEM/assignment-filters/index.js b/src/pages/endpoint/MEM/assignment-filters/index.js index 32fd698663a2..bedf0ef1ada4 100644 --- a/src/pages/endpoint/MEM/assignment-filters/index.js +++ b/src/pages/endpoint/MEM/assignment-filters/index.js @@ -1,27 +1,23 @@ -import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; +import { Button } from "@mui/material"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import Link from "next/link"; import { TrashIcon } from "@heroicons/react/24/outline"; -import { Edit, Add, Book, Sync, CloudDone, Bolt } from "@mui/icons-material"; +import { Edit, Add, Book } from "@mui/icons-material"; import { Stack } from "@mui/system"; -import { useSettings } from "../../../../hooks/use-settings"; -import { useDialog } from "../../../../hooks/use-dialog.js"; -import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; -import { useEffect, useState } from "react"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const Page = () => { const pageTitle = "Assignment Filters"; - const { currentTenant } = useSettings(); - const isAllTenants = currentTenant === "AllTenants"; - const syncDialog = useDialog(); - const [syncQueueId, setSyncQueueId] = useState(null); - const [useReportDB, setUseReportDB] = useState(isAllTenants); - useEffect(() => { - setUseReportDB(currentTenant === "AllTenants"); - }, [currentTenant]); + const reportDB = useCippReportDB({ + apiUrl: "/api/ListAssignmentFilters", + queryKey: "assignment-filters", + cacheName: "IntuneAssignmentFilters", + syncTitle: "Sync Assignment Filters Report", + allowToggle: true, + defaultCached: false, + }); const actions = [ { @@ -75,8 +71,7 @@ const Page = () => { }; const simpleColumns = [ - ...(useReportDB ? ["CacheTimestamp"] : []), - ...(useReportDB && isAllTenants ? ["Tenant"] : []), + ...reportDB.cacheColumns, "displayName", "description", "platform", @@ -84,53 +79,6 @@ const Page = () => { "rule", ]; - const pageActions = [ - - {useReportDB && ( - <> - - - - )} - - - : } - label={useReportDB ? "Cached" : "Live"} - color="primary" - size="small" - onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} - clickable={!isAllTenants} - disabled={isAllTenants} - variant="outlined" - /> - - - , - ]; - return ( <> { - {pageActions} + {reportDB.controls} } - apiUrl={`/api/ListAssignmentFilters${useReportDB ? "?UseReportDB=true" : ""}`} - queryKey={`assignment-filters-${currentTenant}-${useReportDB}`} + apiUrl={reportDB.resolvedApiUrl} + queryKey={reportDB.resolvedQueryKey} actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} /> - { - if (result?.Metadata?.QueueId) { - setSyncQueueId(result?.Metadata?.QueueId); - } - }, - }} - /> + {reportDB.syncDialog} ); }; diff --git a/src/pages/endpoint/MEM/devices/index.js b/src/pages/endpoint/MEM/devices/index.js index 5f1d42f3254e..087052560120 100644 --- a/src/pages/endpoint/MEM/devices/index.js +++ b/src/pages/endpoint/MEM/devices/index.js @@ -4,14 +4,10 @@ import { CippApiDialog } from "../../../../components/CippComponents/CippApiDial import { useSettings } from "../../../../hooks/use-settings"; import { useDialog } from "../../../../hooks/use-dialog.js"; import { EyeIcon } from "@heroicons/react/24/outline"; -import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; +import { Button } from "@mui/material"; import { Stack } from "@mui/system"; -import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; -import { useEffect, useState } from "react"; import { Sync, - CloudDone, - Bolt, RestartAlt, LocationOn, Password, @@ -30,15 +26,7 @@ import { const Page = () => { const pageTitle = "Devices"; const tenantFilter = useSettings().currentTenant; - const isAllTenants = tenantFilter === "AllTenants"; const depSyncDialog = useDialog(); - const syncDialog = useDialog(); - const [syncQueueId, setSyncQueueId] = useState(null); - const [useReportDB, setUseReportDB] = useState(isAllTenants); - - useEffect(() => { - setUseReportDB(tenantFilter === "AllTenants"); - }, [tenantFilter]); const actions = [ { @@ -398,8 +386,6 @@ const Page = () => { }; const simpleColumns = [ - ...(useReportDB ? ["CacheTimestamp"] : []), - ...(useReportDB && isAllTenants ? ["Tenant"] : []), "deviceName", "userPrincipalName", "complianceState", @@ -413,53 +399,6 @@ const Page = () => { "joinType", ]; - const pageActions = [ - - {useReportDB && ( - <> - - - - )} - - - : } - label={useReportDB ? "Cached" : "Live"} - color="primary" - size="small" - onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} - clickable={!isAllTenants} - disabled={isAllTenants} - variant="outlined" - /> - - - , - ]; - return ( <> { apiUrl="/api/ListGraphRequest" apiData={{ Endpoint: "deviceManagement/managedDevices", - ...(useReportDB ? { UseReportDB: true } : {}), }} apiDataKey="Results" actions={actions} - queryKey={`MEMDevices-${tenantFilter}-${useReportDB}`} + queryKey={`MEMDevices-${tenantFilter}`} offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={ @@ -479,7 +417,6 @@ const Page = () => { - {pageActions} } /> @@ -493,25 +430,6 @@ const Page = () => { confirmText: `Are you sure you want to sync Apple Device Enrollment Program (DEP) tokens? This will sync all DEP tokens for ${tenantFilter}. This may take several minutes to complete in the background, and can only be done every 15 minutes.`, }} /> - { - if (result?.Metadata?.QueueId) { - setSyncQueueId(result?.Metadata?.QueueId); - } - }, - }} - /> ); }; diff --git a/src/pages/endpoint/MEM/list-appprotection-policies/index.js b/src/pages/endpoint/MEM/list-appprotection-policies/index.js index f20bee7ffcc2..b864c1a0a447 100644 --- a/src/pages/endpoint/MEM/list-appprotection-policies/index.js +++ b/src/pages/endpoint/MEM/list-appprotection-policies/index.js @@ -1,29 +1,25 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' -import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog.jsx' import { PermissionButton } from '../../../../utils/permissions.js' import { CippPolicyDeployDrawer } from '../../../../components/CippComponents/CippPolicyDeployDrawer.jsx' import { useSettings } from '../../../../hooks/use-settings.js' import { useCippIntunePolicyActions } from '../../../../components/CippComponents/CippIntunePolicyActions.jsx' -import { Sync, CloudDone, Bolt } from '@mui/icons-material' -import { Button, Chip, SvgIcon, Tooltip } from '@mui/material' +import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' import { Stack } from '@mui/system' -import { useDialog } from '../../../../hooks/use-dialog' -import { CippQueueTracker } from '../../../../components/CippTable/CippQueueTracker' -import { useEffect, useState } from 'react' const Page = () => { const pageTitle = 'App Protection & Configuration Policies' const cardButtonPermissions = ['Endpoint.MEM.ReadWrite'] const tenant = useSettings().currentTenant - const isAllTenants = tenant === 'AllTenants' - const syncDialog = useDialog() - const [syncQueueId, setSyncQueueId] = useState(null) - const [useReportDB, setUseReportDB] = useState(isAllTenants) - useEffect(() => { - setUseReportDB(tenant === 'AllTenants') - }, [tenant]) + const reportDB = useCippReportDB({ + apiUrl: '/api/ListAppProtectionPolicies', + queryKey: 'ListAppProtectionPolicies', + cacheName: 'IntuneAppProtectionPolicies', + syncTitle: 'Sync App Protection Policies Report', + allowToggle: true, + defaultCached: false, + }) const actions = useCippIntunePolicyActions(tenant, 'URLName', { templateData: { @@ -46,8 +42,7 @@ const Page = () => { } const simpleColumns = [ - ...(useReportDB ? ['CacheTimestamp'] : []), - ...(useReportDB && isAllTenants ? ['Tenant'] : []), + ...reportDB.cacheColumns, 'displayName', 'PolicyTypeName', 'PolicyAssignment', @@ -55,59 +50,12 @@ const Page = () => { 'lastModifiedDateTime', ] - const pageActions = [ - - {useReportDB && ( - <> - - - - )} - - - : } - label={useReportDB ? 'Cached' : 'Live'} - color="primary" - size="small" - onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} - clickable={!isAllTenants} - disabled={isAllTenants} - variant="outlined" - /> - - - , - ] - return ( <> { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {pageActions} + {reportDB.controls} } /> - { - if (result?.Metadata?.QueueId) { - setSyncQueueId(result?.Metadata?.QueueId) - } - }, - }} - /> + {reportDB.syncDialog} ) } diff --git a/src/pages/endpoint/MEM/list-compliance-policies/index.js b/src/pages/endpoint/MEM/list-compliance-policies/index.js index 0b8e395c3db0..32574567a0ff 100644 --- a/src/pages/endpoint/MEM/list-compliance-policies/index.js +++ b/src/pages/endpoint/MEM/list-compliance-policies/index.js @@ -1,29 +1,25 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; import { PermissionButton } from "../../../../utils/permissions.js"; import { CippPolicyDeployDrawer } from "../../../../components/CippComponents/CippPolicyDeployDrawer.jsx"; import { useSettings } from "../../../../hooks/use-settings.js"; import { useCippIntunePolicyActions } from "../../../../components/CippComponents/CippIntunePolicyActions.jsx"; -import { Sync, CloudDone, Bolt } from "@mui/icons-material"; -import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; import { Stack } from "@mui/system"; -import { useDialog } from "../../../../hooks/use-dialog"; -import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; -import { useEffect, useState } from "react"; const Page = () => { const pageTitle = "Intune Compliance Policies"; const cardButtonPermissions = ["Endpoint.MEM.ReadWrite"]; const tenant = useSettings().currentTenant; - const isAllTenants = tenant === "AllTenants"; - const syncDialog = useDialog(); - const [syncQueueId, setSyncQueueId] = useState(null); - const [useReportDB, setUseReportDB] = useState(isAllTenants); - useEffect(() => { - setUseReportDB(tenant === "AllTenants"); - }, [tenant]); + const reportDB = useCippReportDB({ + apiUrl: "/api/ListCompliancePolicies", + queryKey: "ListCompliancePolicies", + cacheName: "IntuneCompliancePolicies", + syncTitle: "Sync Compliance Policies Report", + allowToggle: true, + defaultCached: false, + }); const actions = useCippIntunePolicyActions(tenant, "deviceCompliancePolicies", { templateData: { @@ -44,8 +40,7 @@ const Page = () => { }; const simpleColumns = [ - ...(useReportDB ? ["CacheTimestamp"] : []), - ...(useReportDB && isAllTenants ? ["Tenant"] : []), + ...reportDB.cacheColumns, "displayName", "PolicyTypeName", "PolicyAssignment", @@ -54,59 +49,12 @@ const Page = () => { "lastModifiedDateTime", ]; - const pageActions = [ - - {useReportDB && ( - <> - - - - )} - - - : } - label={useReportDB ? "Cached" : "Live"} - color="primary" - size="small" - onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} - clickable={!isAllTenants} - disabled={isAllTenants} - variant="outlined" - /> - - - , - ]; - return ( <> { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {pageActions} + {reportDB.controls} } /> - { - if (result?.Metadata?.QueueId) { - setSyncQueueId(result?.Metadata?.QueueId); - } - }, - }} - /> + {reportDB.syncDialog} ); }; diff --git a/src/pages/endpoint/MEM/list-scripts/index.jsx b/src/pages/endpoint/MEM/list-scripts/index.jsx index 11b480fae484..07abf51f5fc1 100644 --- a/src/pages/endpoint/MEM/list-scripts/index.jsx +++ b/src/pages/endpoint/MEM/list-scripts/index.jsx @@ -1,6 +1,5 @@ import { Layout as DashboardLayout } from "../../../../layouts/index"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage"; -import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog"; import { TrashIcon, PencilIcon, @@ -17,19 +16,15 @@ import { IconButton, CircularProgress, DialogActions, - Chip, - SvgIcon, - Tooltip, } from "@mui/material"; import { CippCodeBlock } from "../../../../components/CippComponents/CippCodeBlock"; import { useState, useEffect, useMemo } from "react"; import { useDispatch } from "react-redux"; -import { Close, Save, LaptopChromebook, Sync, CloudDone, Bolt } from "@mui/icons-material"; +import { Close, Save, LaptopChromebook } from "@mui/icons-material"; import { useSettings } from "../../../../hooks/use-settings"; -import { useDialog } from "../../../../hooks/use-dialog"; import { Stack } from "@mui/system"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const assignmentModeOptions = [ { label: "Replace existing assignments", value: "replace" }, @@ -48,14 +43,14 @@ const Page = () => { const [scriptTenant, setScriptTenant] = useState(null); const tenantFilter = useSettings().currentTenant; - const isAllTenants = tenantFilter === "AllTenants"; - const syncDialog = useDialog(); - const [syncQueueId, setSyncQueueId] = useState(null); - const [useReportDB, setUseReportDB] = useState(isAllTenants); - - useEffect(() => { - setUseReportDB(tenantFilter === "AllTenants"); - }, [tenantFilter]); + const reportDB = useCippReportDB({ + apiUrl: "/api/ListIntuneScript", + queryKey: "ListIntuneScript", + cacheName: "IntuneScripts", + syncTitle: "Sync Intune Scripts Report", + allowToggle: true, + defaultCached: false, + }); const dispatch = useDispatch(); @@ -372,8 +367,7 @@ const Page = () => { }; const simpleColumns = [ - ...(useReportDB ? ["CacheTimestamp"] : []), - ...(useReportDB && isAllTenants ? ["Tenant"] : []), + ...reportDB.cacheColumns, "scriptType", "displayName", "ScriptAssignment", @@ -383,63 +377,16 @@ const Page = () => { "lastModifiedDateTime", ]; - const pageActions = [ - - {useReportDB && ( - <> - - - - )} - - - : } - label={useReportDB ? "Cached" : "Live"} - color="primary" - size="small" - onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} - clickable={!isAllTenants} - disabled={isAllTenants} - variant="outlined" - /> - - - , - ]; - return ( <> @@ -504,25 +451,7 @@ const Page = () => { - { - if (result?.Metadata?.QueueId) { - setSyncQueueId(result?.Metadata?.QueueId); - } - }, - }} - /> + {reportDB.syncDialog} ); }; diff --git a/src/pages/endpoint/MEM/reusable-settings/index.js b/src/pages/endpoint/MEM/reusable-settings/index.js index 0c3837e51a0c..75219f0d4136 100644 --- a/src/pages/endpoint/MEM/reusable-settings/index.js +++ b/src/pages/endpoint/MEM/reusable-settings/index.js @@ -1,27 +1,25 @@ -import { Book, DeleteForever, Sync, CloudDone, Bolt } from "@mui/icons-material"; +import { Book, DeleteForever } from "@mui/icons-material"; import { CippReusableSettingsDeployDrawer } from "../../../../components/CippComponents/CippReusableSettingsDeployDrawer.jsx"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { useSettings } from "../../../../hooks/use-settings"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; -import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; import { Stack } from "@mui/system"; -import { useDialog } from "../../../../hooks/use-dialog"; -import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; -import { useEffect, useState } from "react"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const Page = () => { const { currentTenant } = useSettings(); const pageTitle = "Reusable Settings"; - const isAllTenants = currentTenant === "AllTenants"; - const syncDialog = useDialog(); - const [syncQueueId, setSyncQueueId] = useState(null); - const [useReportDB, setUseReportDB] = useState(isAllTenants); + const reportDB = useCippReportDB({ + apiUrl: "/api/ListIntuneReusableSettings", + queryKey: "ListIntuneReusableSettings", + cacheName: "IntuneReusableSettings", + syncTitle: "Sync Reusable Settings Report", + allowToggle: true, + defaultCached: false, + }); + const isAllTenants = reportDB.isAllTenants; - useEffect(() => { - setUseReportDB(currentTenant === "AllTenants"); - }, [currentTenant]); const actions = [ { @@ -64,61 +62,13 @@ const Page = () => { }; const simpleColumns = [ - ...(useReportDB ? ["CacheTimestamp"] : []), - ...(useReportDB && isAllTenants ? ["Tenant"] : []), + ...reportDB.cacheColumns, "displayName", "description", "id", "version", ]; - const pageActions = [ - - {useReportDB && ( - <> - - - - )} - - - : } - label={useReportDB ? "Cached" : "Live"} - color="primary" - size="small" - onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} - clickable={!isAllTenants} - disabled={isAllTenants} - variant="outlined" - /> - - - , - ]; - return ( <> { cardButton={ - {pageActions} + {reportDB.controls} } - apiUrl={`/api/ListIntuneReusableSettings${useReportDB ? "?UseReportDB=true" : ""}`} - queryKey={`ListIntuneReusableSettings-${currentTenant}-${useReportDB}`} + apiUrl={reportDB.resolvedApiUrl} + queryKey={reportDB.resolvedQueryKey} actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} /> - { - if (result?.Metadata?.QueueId) { - setSyncQueueId(result?.Metadata?.QueueId); - } - }, - }} - /> + {reportDB.syncDialog} ); }; diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 4b8b60239a47..fec79a4cf7f9 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -2,14 +2,13 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx"; import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from "@heroicons/react/24/outline"; -import { LaptopMac, Sync, BookmarkAdd, CloudDone, Bolt } from "@mui/icons-material"; +import { LaptopMac, Sync, BookmarkAdd } from "@mui/icons-material"; import { CippApplicationDeployDrawer } from "../../../../components/CippComponents/CippApplicationDeployDrawer"; -import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; +import { Button } from "@mui/material"; import { Stack } from "@mui/system"; import { useSettings } from "../../../../hooks/use-settings.js"; import { useDialog } from "../../../../hooks/use-dialog.js"; -import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; -import { useEffect, useState } from "react"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const assignmentIntentOptions = [ { label: "Required", value: "Required" }, @@ -48,15 +47,16 @@ const mapOdataToAppType = (odataType) => { const Page = () => { const pageTitle = "Applications"; const vppSyncDialog = useDialog(); - const cacheSyncDialog = useDialog(); const tenant = useSettings().currentTenant; - const isAllTenants = tenant === "AllTenants"; - const [syncQueueId, setSyncQueueId] = useState(null); - const [useReportDB, setUseReportDB] = useState(isAllTenants); - useEffect(() => { - setUseReportDB(tenant === "AllTenants"); - }, [tenant]); + const reportDB = useCippReportDB({ + apiUrl: "/api/ListApps", + queryKey: "ListApps", + cacheName: "IntuneApplications", + syncTitle: "Sync Intune Applications Report", + allowToggle: true, + defaultCached: false, + }); const getAssignmentFilterFields = () => [ { @@ -302,8 +302,7 @@ const Page = () => { }; const simpleColumns = [ - ...(useReportDB ? ["CacheTimestamp"] : []), - ...(useReportDB && isAllTenants ? ["Tenant"] : []), + ...reportDB.cacheColumns, "displayName", "AppAssignment", "AppExclude", @@ -312,69 +311,22 @@ const Page = () => { "createdDateTime", ]; - const pageActions = [ - - {useReportDB && ( - <> - - - - )} - - - : } - label={useReportDB ? "Cached" : "Live"} - color="primary" - size="small" - onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)} - clickable={!isAllTenants} - disabled={isAllTenants} - variant="outlined" - /> - - - , - ]; - return ( <> - {pageActions} + {reportDB.controls} } /> @@ -388,25 +340,7 @@ const Page = () => { confirmText: `Are you sure you want to sync Apple Volume Purchase Program (VPP) tokens? This will sync all VPP tokens for ${tenant}.`, }} /> - { - if (result?.Metadata?.QueueId) { - setSyncQueueId(result?.Metadata?.QueueId); - } - }, - }} - /> + {reportDB.syncDialog} ); }; From d7d36a31eb86f6b6f011d483862024515f63757d Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:13:54 +0200 Subject: [PATCH 013/164] feat: Add allTenants support for all the Teams SharePoint pages --- src/pages/teams-share/onedrive/index.js | 51 ++++++++---- src/pages/teams-share/sharepoint/index.js | 82 +++++++++++-------- .../teams-share/teams/business-voice/index.js | 68 +++++++++------ .../teams-share/teams/list-team/index.js | 51 ++++++++---- .../teams-share/teams/teams-activity/index.js | 34 ++++++-- 5 files changed, 191 insertions(+), 95 deletions(-) diff --git a/src/pages/teams-share/onedrive/index.js b/src/pages/teams-share/onedrive/index.js index 8d279cffaf73..82c89d2c2072 100644 --- a/src/pages/teams-share/onedrive/index.js +++ b/src/pages/teams-share/onedrive/index.js @@ -1,10 +1,21 @@ import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; +import { useCippReportDB } from "../../../components/CippComponents/CippReportDBControls"; import { PersonAdd, PersonRemove } from "@mui/icons-material"; const Page = () => { const pageTitle = "OneDrive"; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListSites?type=OneDriveUsageAccount", + queryKey: "ListSites-OneDriveUsageAccount", + cacheName: "Sites", + syncTitle: "Sync OneDrive Report", + syncData: { Types: "OneDriveUsageAccount" }, + allowToggle: true, + defaultCached: false, + }); + const actions = [ { label: "Add permissions to OneDrive", @@ -77,25 +88,31 @@ const Page = () => { ]; return ( - + <> + + {reportDB.syncDialog} + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index 21cefc406ca4..0e751f10aa49 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -1,6 +1,7 @@ import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; import { Button } from "@mui/material"; +import { Stack } from "@mui/system"; import { Add, AddToPhotos, @@ -13,11 +14,22 @@ import { import Link from "next/link"; import { CippDataTable } from "../../../components/CippTable/CippDataTable"; import { useSettings } from "../../../hooks/use-settings"; +import { useCippReportDB } from "../../../components/CippComponents/CippReportDBControls"; const Page = () => { const pageTitle = "SharePoint Sites"; const tenantFilter = useSettings().currentTenant; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListSites?type=SharePointSiteUsage", + queryKey: "ListSites-SharePointSiteUsage", + cacheName: "Sites", + syncTitle: "Sync SharePoint Sites Report", + syncData: { Types: "SharePointSiteUsage" }, + allowToggle: true, + defaultCached: true, + }); + const actions = [ { label: "Add Member", @@ -213,40 +225,46 @@ const Page = () => { }; return ( - - - - - } - /> + <> + + + + {reportDB.controls} + + } + /> + {reportDB.syncDialog} + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/teams-share/teams/business-voice/index.js b/src/pages/teams-share/teams/business-voice/index.js index a3aa56764153..3c9354706147 100644 --- a/src/pages/teams-share/teams/business-voice/index.js +++ b/src/pages/teams-share/teams/business-voice/index.js @@ -1,10 +1,20 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; import { PersonAdd, PersonRemove, LocationOn } from "@mui/icons-material"; const Page = () => { const pageTitle = "Teams Business Voice"; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListTeamsVoice", + queryKey: "ListTeamsVoice", + cacheName: "TeamsVoice", + syncTitle: "Sync Teams Business Voice Report", + allowToggle: true, + defaultCached: false, + }); + const actions = [ // the modal dropdowns that were added below may not exist yet, and will need to be tested. { @@ -81,34 +91,40 @@ const Page = () => { }; return ( - + <> + + {reportDB.syncDialog} + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/teams-share/teams/list-team/index.js b/src/pages/teams-share/teams/list-team/index.js index bf48cccb08a3..99b51994bafe 100644 --- a/src/pages/teams-share/teams/list-team/index.js +++ b/src/pages/teams-share/teams/list-team/index.js @@ -1,13 +1,24 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { Button } from "@mui/material"; +import { Stack } from "@mui/system"; import { Delete, GroupAdd } from "@mui/icons-material"; import Link from "next/link"; import { Edit } from "@mui/icons-material"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const Page = () => { const pageTitle = "Teams"; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListTeams?type=list", + queryKey: "ListTeams-list", + cacheName: "Teams", + syncTitle: "Sync Teams Report", + allowToggle: true, + defaultCached: false, + }); + const actions = [ { label: "Edit Group", @@ -32,22 +43,34 @@ const Page = () => { ]; return ( - - - - } - /> + <> + + + {reportDB.controls} + + } + /> + {reportDB.syncDialog} + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/teams-share/teams/teams-activity/index.js b/src/pages/teams-share/teams/teams-activity/index.js index 2f2797a57cbb..f5fb2bb53754 100644 --- a/src/pages/teams-share/teams/teams-activity/index.js +++ b/src/pages/teams-share/teams/teams-activity/index.js @@ -1,18 +1,40 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const Page = () => { const pageTitle = "Teams Activity List"; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListTeamsActivity?type=TeamsUserActivityUser", + queryKey: "ListTeamsActivity-TeamsUserActivityUser", + cacheName: "TeamsActivity", + syncTitle: "Sync Teams Activity Report", + allowToggle: true, + defaultCached: false, + }); + return ( - + <> + + {reportDB.syncDialog} + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; From b05e0923d8afc286f7ca5591056bf847c8dbd755 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 8 May 2026 22:36:34 +0200 Subject: [PATCH 014/164] feat(standards): add by-standard alignment summary view --- .../CippComponents/CippTranslations.jsx | 1 + src/components/CippTable/CippDataTable.js | 15 +- .../CippTable/CippDataTableButton.jsx | 11 +- src/pages/tenant/standards/alignment/index.js | 565 ++++++++++++++++-- src/utils/get-cipp-column-size.js | 2 + src/utils/get-cipp-formatting.js | 6 +- 6 files changed, 545 insertions(+), 55 deletions(-) diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx index c4b337ded761..8eb5ada15b07 100644 --- a/src/components/CippComponents/CippTranslations.jsx +++ b/src/components/CippComponents/CippTranslations.jsx @@ -9,6 +9,7 @@ export const CippTranslations = { surName: "Surname", city: "City", tenant: "Tenant", + tenants: "Tenants", tenantFilter: "Tenant", showTenantInformation: "Show Tenant Information", refreshTenantList: "Refresh tenant list", diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 0fc4f87432b7..253327713912 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -579,6 +579,9 @@ export const CippDataTable = (props) => { }, [columns.length, usedData, queryKey, settings?.currentTenant, filterTypeMap]) const createDialog = useDialog() + const hasActions = !!actions + const hasOffCanvas = !!offCanvas + const hasOnChange = !!onChange // Compute modeInfo via useMemo so it stays stable but updates when relevant inputs change. const modeInfo = useMemo( @@ -593,7 +596,7 @@ export const CippDataTable = (props) => { maxHeightOffset, settings ), - [simple, !!actions, !!offCanvas, !!onChange, maxHeightOffset, settings?.tablePageSize?.value] + [simple, hasActions, hasOffCanvas, hasOnChange, maxHeightOffset, settings?.tablePageSize?.value] ) // Include updateTrigger in data memo to force re-render when license backfill completes @@ -651,7 +654,15 @@ export const CippDataTable = (props) => { const muiTableBodyRowProps = useMemo(() => { if (offCanvasOnRowClick && offCanvas) { return ({ row }) => ({ - onClick: () => { + onClick: (event) => { + if ( + event.target?.closest?.( + 'button, a, input, textarea, select, [role="button"], [role="menuitem"], [data-no-row-click="true"]' + ) + ) { + return + } + setOffCanvasData(row.original) const filteredRowsArray = table?.getFilteredRowModel?.()?.rows if (filteredRowsArray) { diff --git a/src/components/CippTable/CippDataTableButton.jsx b/src/components/CippTable/CippDataTableButton.jsx index 79eec0f04bc5..86c3c887e1e9 100644 --- a/src/components/CippTable/CippDataTableButton.jsx +++ b/src/components/CippTable/CippDataTableButton.jsx @@ -5,7 +5,9 @@ import { getCippTranslation } from "../../utils/get-cipp-translation"; const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { const [openDialogs, setOpenDialogs] = useState([]); - const handleOpenDialog = () => { + const handleOpenDialog = (event) => { + event?.stopPropagation(); + let dataArray; if (Array.isArray(data)) { @@ -21,7 +23,8 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { setOpenDialogs([...openDialogs, dataArray]); }; - const handleCloseDialog = (index) => { + const handleCloseDialog = (index, event) => { + event?.stopPropagation?.(); setOpenDialogs(openDialogs.filter((_, i) => i !== index)); }; const dataIsNotANullArray = @@ -48,7 +51,9 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { handleCloseDialog(index)} + onClose={(event) => handleCloseDialog(index, event)} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} fullWidth maxWidth="lg" > diff --git a/src/pages/tenant/standards/alignment/index.js b/src/pages/tenant/standards/alignment/index.js index e81ce99d92e7..ce413aa54393 100644 --- a/src/pages/tenant/standards/alignment/index.js +++ b/src/pages/tenant/standards/alignment/index.js @@ -1,16 +1,245 @@ import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { ApiGetCallWithPagination } from '../../../../api/ApiCall' +import { useSettings } from '../../../../hooks/use-settings' import { Delete, Edit } from '@mui/icons-material' -import { EyeIcon, ListBulletIcon, ChartBarIcon } from '@heroicons/react/24/outline' +import { EyeIcon, ListBulletIcon, ChartBarIcon, Squares2X2Icon } from '@heroicons/react/24/outline' import tabOptions from '../tabOptions.json' -import { useState } from 'react' -import { Box, Chip, Divider, Stack, Tooltip, Typography } from '@mui/material' +import { useEffect, useMemo, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { + Box, + Chip, + Divider, + Stack, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@mui/material' import standardsData from '../../../../data/standards.json' +const complianceColors = { + compliant: 'success', + 'non-compliant': 'error', + 'accepted deviation': 'info', + 'customer specific': 'info', + 'license missing': 'warning', + 'reporting disabled': 'default', +} + +const compliancePriority = { + compliant: 10, + 'reporting disabled': 20, + 'customer specific': 30, + 'accepted deviation': 40, + 'license missing': 50, + 'non-compliant': 60, +} + +const getComplianceStatus = (status) => String(status ?? 'Unknown').trim() || 'Unknown' + +const getComplianceColor = (status) => + complianceColors[getComplianceStatus(status).toLowerCase()] ?? 'default' + +const getCompliancePriority = (status) => + compliancePriority[getComplianceStatus(status).toLowerCase()] ?? 0 + +const isAlignedComplianceStatus = (status) => + ['compliant', 'accepted deviation', 'customer specific'].includes( + getComplianceStatus(status).toLowerCase() + ) + +const getPageRows = (page) => { + if (Array.isArray(page)) return page + if (Array.isArray(page?.Results)) return page.Results + if (Array.isArray(page?.Data)) return page.Data + if (Array.isArray(page?.data)) return page.data + if (Array.isArray(page?.value)) return page.value + return [] +} + +const getStandardInfo = (standardId) => { + const baseName = standardId?.split('.').slice(0, -1).join('.') + return ( + standardsData.find((s) => s.name === standardId) ?? + standardsData.find((s) => s.name === baseName) + ) +} + const Page = () => { const pageTitle = 'Standard & Drift Alignment' - const [granular, setGranular] = useState(false) + const tenant = useSettings().currentTenant + const [viewMode, setViewMode] = useState('summary') + const [byStandardTenantFilter, setByStandardTenantFilter] = useState('all') + const isSummary = viewMode === 'summary' + const isGranular = viewMode === 'granular' + const isByStandard = viewMode === 'byStandard' + + const { + data: byStandardApiData, + fetchNextPage: fetchNextByStandardPage, + hasNextPage: byStandardHasNextPage, + isFetching: byStandardIsFetching, + isSuccess: byStandardIsSuccess, + } = ApiGetCallWithPagination({ + url: '/api/ListTenantAlignment', + data: { tenantFilter: tenant, granular: 'true' }, + queryKey: `listTenantAlignment-byStandard-source-${tenant}`, + waiting: isByStandard, + }) + + useEffect(() => { + if (isByStandard && byStandardIsSuccess && byStandardHasNextPage && !byStandardIsFetching) { + fetchNextByStandardPage() + } + }, [ + byStandardApiData?.pages?.length, + byStandardHasNextPage, + byStandardIsFetching, + byStandardIsSuccess, + fetchNextByStandardPage, + isByStandard, + ]) + + const byStandardSourceData = useMemo( + () => byStandardApiData?.pages?.flatMap((page) => getPageRows(page)) ?? [], + [byStandardApiData] + ) + + const byStandardData = useMemo(() => { + const groupedStandards = new Map() + + byStandardSourceData.forEach((row) => { + const standardKey = row.standardId || row.standardName + if (!standardKey) return + + const standardInfo = getStandardInfo(row.standardId) + const standardName = standardInfo?.label ?? row.standardName ?? standardKey + + if (!groupedStandards.has(standardKey)) { + groupedStandards.set(standardKey, { + standardId: standardKey, + standardName, + category: standardInfo?.cat ?? 'Uncategorized', + standardTypes: new Set(), + tenants: new Map(), + }) + } + + const standard = groupedStandards.get(standardKey) + const standardType = row.standardType ?? row.templateType + if (standardType) standard.standardTypes.add(standardType) + + const tenantKey = row.tenantFilter ?? row.tenantName ?? row.Tenant ?? 'Unknown' + const status = getComplianceStatus(row.complianceStatus) + const tenant = standard.tenants.get(tenantKey) ?? { + tenantFilter: tenantKey, + complianceStatus: status, + rows: [], + } + + tenant.rows.push(row) + if (getCompliancePriority(status) > getCompliancePriority(tenant.complianceStatus)) { + tenant.complianceStatus = status + } + standard.tenants.set(tenantKey, tenant) + }) + + return Array.from(groupedStandards.values()) + .map((standard) => { + const tenants = Array.from(standard.tenants.values()) + .map((tenant) => { + const templateNames = [ + ...new Set(tenant.rows.map((row) => row.templateName).filter(Boolean)), + ] + const latestDataCollection = tenant.rows + .map((row) => row.latestDataCollection) + .filter(Boolean) + .sort((a, b) => new Date(b) - new Date(a))[0] + + return { + tenantFilter: tenant.tenantFilter, + complianceStatus: tenant.complianceStatus, + templateName: templateNames.join(', ') || 'N/A', + latestDataCollection, + rowCount: tenant.rows.length, + rows: tenant.rows, + } + }) + .sort((a, b) => a.tenantFilter.localeCompare(b.tenantFilter)) + + const counts = tenants.reduce( + (acc, tenant) => { + switch (getComplianceStatus(tenant.complianceStatus).toLowerCase()) { + case 'compliant': + acc.compliantCount += 1 + break + case 'non-compliant': + acc.nonCompliantCount += 1 + break + case 'accepted deviation': + acc.acceptedDeviationCount += 1 + break + case 'customer specific': + acc.customerSpecificCount += 1 + break + case 'license missing': + acc.licenseMissingCount += 1 + break + case 'reporting disabled': + acc.reportingDisabledCount += 1 + break + default: + acc.otherCount += 1 + } + return acc + }, + { + compliantCount: 0, + nonCompliantCount: 0, + acceptedDeviationCount: 0, + customerSpecificCount: 0, + licenseMissingCount: 0, + reportingDisabledCount: 0, + otherCount: 0, + } + ) + + const totalTenants = tenants.length + const alignedCount = + counts.compliantCount + counts.acceptedDeviationCount + counts.customerSpecificCount + const compliancePercentage = totalTenants + ? Math.round((alignedCount / totalTenants) * 100) + : 0 + const licenseMissingPercentage = totalTenants + ? Math.round((counts.licenseMissingCount / totalTenants) * 100) + : 0 + + return { + standardId: standard.standardId, + standardName: standard.standardName, + category: standard.category, + standardType: Array.from(standard.standardTypes).sort().join(', ') || 'N/A', + totalTenants, + alignedCount, + compliancePercentage, + alignmentScore: compliancePercentage, + LicenseMissingPercentage: licenseMissingPercentage, + complianceScore: `${compliancePercentage}%`, + summaryStatus: compliancePercentage === 100 ? 'Fully Compliant' : 'Needs Attention', + hasNonCompliant: counts.nonCompliantCount > 0 ? 'Yes' : 'No', + hasLicenseMissing: counts.licenseMissingCount > 0 ? 'Yes' : 'No', + hasAcceptedDeviation: counts.acceptedDeviationCount > 0 ? 'Yes' : 'No', + isFullyCompliant: compliancePercentage === 100 ? 'Yes' : 'No', + tenants, + ...counts, + } + }) + .sort((a, b) => a.standardName.localeCompare(b.standardName)) + }, [byStandardSourceData]) const summaryFilterList = [ { @@ -53,6 +282,29 @@ const Page = () => { }, ] + const byStandardFilterList = [ + { + filterName: 'Fully Compliant', + value: [{ id: 'isFullyCompliant', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'Has Non-Compliant', + value: [{ id: 'hasNonCompliant', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'License Missing', + value: [{ id: 'hasLicenseMissing', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'Accepted Deviation', + value: [{ id: 'hasAcceptedDeviation', value: 'Yes' }], + type: 'column', + }, + ] + const summaryActions = [ { label: 'View Tenant Report', @@ -178,16 +430,7 @@ const Page = () => { standardsData.find((s) => s.name === baseName)?.label ?? row.standardName - const complianceColors = { - compliant: 'success', - 'non-compliant': 'error', - 'accepted deviation': 'info', - 'customer specific': 'info', - 'license missing': 'warning', - 'reporting disabled': 'default', - } - const statusColor = - complianceColors[String(row.complianceStatus ?? '').toLowerCase()] ?? 'default' + const statusColor = getComplianceColor(row.complianceStatus) const properties = [ { label: 'Standard', value: prettyName }, @@ -434,39 +677,241 @@ const Page = () => { }, } + const byStandardOffCanvas = { + size: 'md', + title: 'Standard Tenant Summary', + contentPadding: 0, + children: (row) => { + const standardInfo = getStandardInfo(row.standardId) + const properties = [ + { label: 'Standard', value: row.standardName }, + { label: 'Category', value: row.category }, + { label: 'Type', value: row.standardType }, + { label: 'Tenants', value: row.totalTenants }, + { label: 'Compliance', value: `${row.alignmentScore}%` }, + { label: 'Licenses Missing', value: `${row.LicenseMissingPercentage}%` }, + ] + const tenants = row.tenants ?? [] + const compliantTenants = tenants.filter((tenant) => + isAlignedComplianceStatus(tenant.complianceStatus) + ) + const nonCompliantTenants = tenants.filter( + (tenant) => !isAlignedComplianceStatus(tenant.complianceStatus) + ) + const filteredTenants = + byStandardTenantFilter === 'compliant' + ? compliantTenants + : byStandardTenantFilter === 'nonCompliant' + ? nonCompliantTenants + : tenants + + return ( + + } + sx={{ borderBottom: '1px solid', borderColor: 'divider' }} + > + {properties.map(({ label, value }) => ( + + + {label} + + + {value ?? 'N/A'} + + + ))} + + + {standardInfo?.helpText && ( + + + Description + + + {standardInfo.helpText} + + + )} + + + + + Tenant Compliance + + { + if (newFilter !== null) setByStandardTenantFilter(newFilter) + }} + sx={{ alignSelf: { xs: 'flex-start', sm: 'center' } }} + > + All ({tenants.length}) + Compliant ({compliantTenants.length}) + + Noncompliant ({nonCompliantTenants.length}) + + + + {filteredTenants.length === 0 && ( + + No tenants match this filter. + + )} + {filteredTenants.map((tenant) => ( + + + + + {tenant.tenantFilter} + + + Template: {tenant.templateName} + + + + + + Last Applied:{' '} + {tenant.latestDataCollection + ? new Date(tenant.latestDataCollection).toLocaleString() + : 'N/A'} + {tenant.rowCount > 1 ? ` (${tenant.rowCount} template matches)` : ''} + + + ))} + + + ) + }, + } + const modeToggle = ( - - - ) : ( - - ) - } - label={granular ? 'Per Standard' : 'Summary'} - onClick={() => setGranular((v) => !v)} - color="primary" - variant="filled" - size="small" - clickable - /> - + { + if (newViewMode !== null) setViewMode(newViewMode) + }} + > + + + + + Summary + + + + + + + + Per Standard + + + + + + + + By Standard + + + + ) return ( { 'standardType', 'latestDataCollection', ] - : [ - 'tenantFilter', - 'standardName', - 'standardType', - 'alignmentScore', - 'LicenseMissingPercentage', - 'combinedAlignmentScore', - 'pendingDeviationsCount', - 'deniedDeviationsCount', - ] + : isByStandard + ? [ + 'standardName', + 'category', + 'standardType', + 'totalTenants', + 'tenants', + 'compliancePercentage', + 'LicenseMissingPercentage', + 'alignedCount', + 'compliantCount', + 'nonCompliantCount', + 'licenseMissingCount', + 'acceptedDeviationCount', + ] + : [ + 'tenantFilter', + 'standardName', + 'standardType', + 'alignmentScore', + 'LicenseMissingPercentage', + 'combinedAlignmentScore', + 'pendingDeviationsCount', + 'deniedDeviationsCount', + ] + } + queryKey={ + isGranular + ? 'listTenantAlignment-granular' + : isByStandard + ? 'listTenantAlignment-byStandard' + : 'listTenantAlignment' } - queryKey={granular ? 'listTenantAlignment-granular' : 'listTenantAlignment'} - offCanvas={granular ? granularOffCanvas : undefined} + offCanvas={isGranular ? granularOffCanvas : isByStandard ? byStandardOffCanvas : undefined} + offCanvasOnRowClick={isByStandard} cardButton={modeToggle} /> ) diff --git a/src/utils/get-cipp-column-size.js b/src/utils/get-cipp-column-size.js index a62c579b8d48..d24c5d549b8c 100644 --- a/src/utils/get-cipp-column-size.js +++ b/src/utils/get-cipp-column-size.js @@ -15,6 +15,8 @@ export const getCippColumnSize = (accessorKey, header) => { switch (accessorKey) { case 'alignmentScore': case 'combinedAlignmentScore': + case 'compliancePercentage': + case 'complianceScore': case 'LicenseMissingPercentage': case 'ScorePercentage': return { size: 250, minSize: 250 } diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 62473638e095..9680ecaca029 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -259,7 +259,11 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr return isText ? data : data } - if (cellName === 'alignmentScore' || cellName === 'combinedAlignmentScore') { + if ( + cellName === 'alignmentScore' || + cellName === 'combinedAlignmentScore' || + cellName === 'compliancePercentage' + ) { // Handle alignment score, return a percentage with a label return isText ? ( `${data}%` From 45f1d72bf7e1376fbc2e4f58ecc39d9a4c3e363b Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 10 May 2026 01:25:58 +0200 Subject: [PATCH 015/164] purview adding --- .../CippDeployCompliancePolicyDrawer.jsx | 19 +-- src/data/standards.json | 104 +++++++++++++++ src/layouts/config.js | 120 +++++++++--------- .../compliance/dlp-templates/index.js | 1 + src/pages/security/compliance/dlp/index.js | 1 + .../compliance/labels-templates/index.js | 1 + src/pages/security/compliance/labels/index.js | 1 + .../compliance/retention-templates/index.js | 1 + .../security/compliance/retention/index.js | 1 + .../compliance/sit-templates/index.js | 1 + src/pages/security/compliance/sit/index.js | 13 +- 11 files changed, 182 insertions(+), 81 deletions(-) diff --git a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx index 3cf35e3cb2f8..c03c976f9f94 100644 --- a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx +++ b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx @@ -88,18 +88,21 @@ const MODE_CONFIG = { listTemplatesUrl: "/api/ListSensitiveInfoTypeTemplates", templateQueryKey: "TemplateListSensitiveInfoType", relatedQueryKeys: ["ListSensitiveInfoType", "ListSensitiveInfoTypeTemplates"], - placeholder: `{ - "Name": "Custom Employee ID", - "Description": "Internal Employee ID format EMP-NNNNN", + placeholder: `// Simple mode — backend wraps the regex in a rule pack for you +{ + "Name": "Acme Employee ID", + "Description": "Matches Acme employee IDs in the format EMP-NNNNN", "Pattern": "EMP-\\\\d{5}", - "Confidence": "High", - "Recommended": true + "Confidence": 85, + "PatternsProximity": 300, + "PublisherName": "Acme Corp" } -// Or with a base64-encoded XML rule pack: +// Advanced mode — provide your own rule pack XML, base64-encoded // { -// "Name": "Custom Rule Pack", -// "FileDataBase64": "" +// "Name": "Acme Custom Rule Pack", +// "Description": "Multi-pattern rule pack", +// "FileDataBase64": "" // }`, }, }; diff --git a/src/data/standards.json b/src/data/standards.json index cc708f747177..5b70e1eba3b1 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -6205,6 +6205,110 @@ ], "requiredCapabilities": ["EXCHANGE_S_STANDARD", "EXCHANGE_S_ENTERPRISE", "EXCHANGE_LITE"] }, + { + "name": "standards.DlpCompliancePolicyTemplate", + "label": "DLP Compliance Policy Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { "report": false, "warn": true, "remediate": false }, + "impact": "Medium Impact", + "addedDate": "2026-05-10", + "helpText": "Deploy Microsoft Purview DLP compliance policies from CIPP templates.", + "executiveText": "Deploys Data Loss Prevention policies from a standardized template library. Ensures consistent DLP coverage across tenants for sensitive data such as financial, identity, and regulated content.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "dlpCompliancePolicyTemplate", + "label": "Select DLP Compliance Policy Templates", + "api": { + "url": "/api/ListDlpCompliancePolicyTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListDlpCompliancePolicyTemplates" + } + } + ] + }, + { + "name": "standards.RetentionCompliancePolicyTemplate", + "label": "Retention Compliance Policy Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { "report": false, "warn": true, "remediate": false }, + "impact": "Medium Impact", + "addedDate": "2026-05-10", + "helpText": "Deploy Microsoft Purview retention compliance policies from CIPP templates.", + "executiveText": "Deploys retention policies that govern how long content is preserved in Exchange, SharePoint, OneDrive, and Teams. Enforces consistent compliance retention across tenants for regulatory and legal hold needs.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "retentionCompliancePolicyTemplate", + "label": "Select Retention Compliance Policy Templates", + "api": { + "url": "/api/ListRetentionCompliancePolicyTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListRetentionCompliancePolicyTemplates" + } + } + ] + }, + { + "name": "standards.SensitivityLabelTemplate", + "label": "Sensitivity Label Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { "report": false, "warn": true, "remediate": false }, + "impact": "Medium Impact", + "addedDate": "2026-05-10", + "helpText": "Deploy Microsoft Purview sensitivity labels from CIPP templates.", + "executiveText": "Deploys sensitivity labels for classification and protection of files, emails, and Microsoft 365 group content. Ensures consistent classification taxonomy and encryption settings across tenants.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "sensitivityLabelTemplate", + "label": "Select Sensitivity Label Templates", + "api": { + "url": "/api/ListSensitivityLabelTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListSensitivityLabelTemplates" + } + } + ] + }, + { + "name": "standards.SensitiveInfoTypeTemplate", + "label": "Sensitive Information Type Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { "report": false, "warn": true, "remediate": false }, + "impact": "Low Impact", + "addedDate": "2026-05-10", + "helpText": "Deploy custom Microsoft Purview Sensitive Information Types from CIPP templates.", + "executiveText": "Deploys custom Sensitive Information Types so DLP policies can detect organization-specific identifiers — employee IDs, project codenames, internal account numbers — across tenants consistently.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "sensitiveInfoTypeTemplate", + "label": "Select Sensitive Information Type Templates", + "api": { + "url": "/api/ListSensitiveInfoTypeTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListSensitiveInfoTypeTemplates" + } + } + ] + }, { "name": "standards.AssignmentFilterTemplate", "label": "Assignment Filter Template", diff --git a/src/layouts/config.js b/src/layouts/config.js index c820f5664acc..0cc0c8ec303b 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -301,11 +301,10 @@ export const nativeMenuItems = [ 'Security.Alert.*', 'Tenant.DeviceCompliance.*', 'Security.SafeLinksPolicy.*', - // TEMP: Purview Compliance menu hidden for dev build - // 'Security.DlpCompliancePolicy.*', - // 'Security.RetentionCompliancePolicy.*', - // 'Security.SensitivityLabel.*', - // 'Security.SensitiveInfoType.*', + 'Security.DlpCompliancePolicy.*', + 'Security.RetentionCompliancePolicy.*', + 'Security.SensitivityLabel.*', + 'Security.SensitiveInfoType.*', ], items: [ { @@ -388,62 +387,61 @@ export const nativeMenuItems = [ }, ], }, - // TEMP: Purview Compliance menu hidden for dev build - // { - // title: 'Purview Compliance', - // permissions: [ - // 'Security.DlpCompliancePolicy.*', - // 'Security.RetentionCompliancePolicy.*', - // 'Security.SensitivityLabel.*', - // 'Security.SensitiveInfoType.*', - // ], - // items: [ - // { - // title: 'DLP Policies', - // path: '/security/compliance/dlp', - // permissions: ['Security.DlpCompliancePolicy.*'], - // }, - // { - // title: 'DLP Policy Templates', - // path: '/security/compliance/dlp-templates', - // permissions: ['Security.DlpCompliancePolicy.*'], - // scope: 'global', - // }, - // { - // title: 'Retention Policies', - // path: '/security/compliance/retention', - // permissions: ['Security.RetentionCompliancePolicy.*'], - // }, - // { - // title: 'Retention Policy Templates', - // path: '/security/compliance/retention-templates', - // permissions: ['Security.RetentionCompliancePolicy.*'], - // scope: 'global', - // }, - // { - // title: 'Sensitivity Labels', - // path: '/security/compliance/labels', - // permissions: ['Security.SensitivityLabel.*'], - // }, - // { - // title: 'Sensitivity Label Templates', - // path: '/security/compliance/labels-templates', - // permissions: ['Security.SensitivityLabel.*'], - // scope: 'global', - // }, - // { - // title: 'Sensitive Information Types', - // path: '/security/compliance/sit', - // permissions: ['Security.SensitiveInfoType.*'], - // }, - // { - // title: 'Sensitive Info Type Templates', - // path: '/security/compliance/sit-templates', - // permissions: ['Security.SensitiveInfoType.*'], - // scope: 'global', - // }, - // ], - // }, + { + title: 'Purview Compliance', + permissions: [ + 'Security.DlpCompliancePolicy.*', + 'Security.RetentionCompliancePolicy.*', + 'Security.SensitivityLabel.*', + 'Security.SensitiveInfoType.*', + ], + items: [ + { + title: 'DLP Policies', + path: '/security/compliance/dlp', + permissions: ['Security.DlpCompliancePolicy.*'], + }, + { + title: 'DLP Policy Templates', + path: '/security/compliance/dlp-templates', + permissions: ['Security.DlpCompliancePolicy.*'], + scope: 'global', + }, + { + title: 'Retention Policies', + path: '/security/compliance/retention', + permissions: ['Security.RetentionCompliancePolicy.*'], + }, + { + title: 'Retention Policy Templates', + path: '/security/compliance/retention-templates', + permissions: ['Security.RetentionCompliancePolicy.*'], + scope: 'global', + }, + { + title: 'Sensitivity Labels', + path: '/security/compliance/labels', + permissions: ['Security.SensitivityLabel.*'], + }, + { + title: 'Sensitivity Label Templates', + path: '/security/compliance/labels-templates', + permissions: ['Security.SensitivityLabel.*'], + scope: 'global', + }, + { + title: 'Sensitive Information Types', + path: '/security/compliance/sit', + permissions: ['Security.SensitiveInfoType.*'], + }, + { + title: 'Sensitive Info Type Templates', + path: '/security/compliance/sit-templates', + permissions: ['Security.SensitiveInfoType.*'], + scope: 'global', + }, + ], + }, ], }, { diff --git a/src/pages/security/compliance/dlp-templates/index.js b/src/pages/security/compliance/dlp-templates/index.js index b2b597c51167..99e949f700d1 100644 --- a/src/pages/security/compliance/dlp-templates/index.js +++ b/src/pages/security/compliance/dlp-templates/index.js @@ -81,6 +81,7 @@ const Page = () => { { { { { { { { const cardButtonPermissions = ["Security.SensitiveInfoType.ReadWrite"]; const actions = [ - { - label: "Create template based on SIT", - type: "POST", - icon: , - url: "/api/AddSensitiveInfoTypeTemplate", - dataFunction: (data) => { - return { ...data }; - }, - confirmText: - "Are you sure you want to create a template based on this Sensitive Information Type?", - }, { label: "Delete SIT", type: "POST", @@ -63,6 +51,7 @@ const Page = () => { Date: Sun, 10 May 2026 14:51:51 +0200 Subject: [PATCH 016/164] fix alert mode --- src/data/standards.json | 8 ++++---- src/pages/security/compliance/retention/index.js | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index 5b70e1eba3b1..c7a825e06120 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -6210,7 +6210,7 @@ "label": "DLP Compliance Policy Template", "multi": true, "cat": "Templates", - "disabledFeatures": { "report": false, "warn": true, "remediate": false }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", "helpText": "Deploy Microsoft Purview DLP compliance policies from CIPP templates.", @@ -6236,7 +6236,7 @@ "label": "Retention Compliance Policy Template", "multi": true, "cat": "Templates", - "disabledFeatures": { "report": false, "warn": true, "remediate": false }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", "helpText": "Deploy Microsoft Purview retention compliance policies from CIPP templates.", @@ -6262,7 +6262,7 @@ "label": "Sensitivity Label Template", "multi": true, "cat": "Templates", - "disabledFeatures": { "report": false, "warn": true, "remediate": false }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Medium Impact", "addedDate": "2026-05-10", "helpText": "Deploy Microsoft Purview sensitivity labels from CIPP templates.", @@ -6288,7 +6288,7 @@ "label": "Sensitive Information Type Template", "multi": true, "cat": "Templates", - "disabledFeatures": { "report": false, "warn": true, "remediate": false }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "Low Impact", "addedDate": "2026-05-10", "helpText": "Deploy custom Microsoft Purview Sensitive Information Types from CIPP templates.", diff --git a/src/pages/security/compliance/retention/index.js b/src/pages/security/compliance/retention/index.js index db9fd2d6eae8..962301013f29 100644 --- a/src/pages/security/compliance/retention/index.js +++ b/src/pages/security/compliance/retention/index.js @@ -16,9 +16,7 @@ const Page = () => { type: "POST", icon: , url: "/api/AddRetentionCompliancePolicyTemplate", - dataFunction: (data) => { - return { ...data }; - }, + data: { Identity: "Name" }, confirmText: "Are you sure you want to create a template based on this retention policy?", }, { From 98d5d94a0d122d5fbc7380d5abe5cb02fba366fd Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 11 May 2026 21:49:35 +0800 Subject: [PATCH 017/164] Custom Test - Alert on X statuses --- src/pages/tools/custom-tests/add.jsx | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/pages/tools/custom-tests/add.jsx b/src/pages/tools/custom-tests/add.jsx index 41b01afdd9ea..e4da31a623c5 100644 --- a/src/pages/tools/custom-tests/add.jsx +++ b/src/pages/tools/custom-tests/add.jsx @@ -37,6 +37,7 @@ import { renderCustomScriptMarkdownTemplate } from '../../../utils/customScriptT import { useSettings } from '../../../hooks/use-settings' import CippFormPage from '../../../components/CippFormPages/CippFormPage' import CippFormComponent from '../../../components/CippComponents/CippFormComponent' +import { CippFormCondition } from '../../../components/CippComponents/CippFormCondition' import { CippApiResults } from '../../../components/CippComponents/CippApiResults' import { CippCodeBlock } from '../../../components/CippComponents/CippCodeBlock' import { markdownStyles } from '../../../components/CippTestDetail/CippTestDetailOffCanvas' @@ -117,6 +118,7 @@ const Page = () => { ScriptContent: '', Enabled: false, AlertOnFailure: false, + AlertStatuses: [{ value: 'Failed', label: 'Failed' }], ReturnType: 'JSON', ResultMode: { value: 'Auto', label: 'Auto' }, MarkdownTemplate: '', @@ -146,6 +148,12 @@ const Page = () => { ScriptContent: script.ScriptContent || '', Enabled: script.Enabled || false, AlertOnFailure: script.AlertOnFailure || false, + AlertStatuses: script.AlertStatuses + ? (typeof script.AlertStatuses === 'string' + ? JSON.parse(script.AlertStatuses) + : script.AlertStatuses + ).map((s) => ({ value: s, label: s })) + : [{ value: 'Failed', label: 'Failed' }], ReturnType: script.ReturnType || 'JSON', ResultMode: toSelectOption(script.ResultMode, 'Auto'), MarkdownTemplate: script.MarkdownTemplate || '', @@ -253,6 +261,9 @@ const Page = () => { ScriptContent: data.ScriptContent, Enabled: data.Enabled, AlertOnFailure: data.AlertOnFailure, + AlertStatuses: data.AlertOnFailure + ? (data.AlertStatuses?.map(s => s.value) || ['Failed']) + : [], ReturnType: data.ReturnType, ResultMode: data.ResultMode?.value ?? data.ResultMode, MarkdownTemplate: data.MarkdownTemplate, @@ -401,6 +412,20 @@ const Page = () => { 'When enabled, a failed test triggers an alert routed to your configured notification channels (email, webhook, or PSA).', } + const alertStatusesField = { + name: 'AlertStatuses', + label: 'Alert on Status', + type: 'autoComplete', + multiple: true, + options: [ + { label: 'Failed', value: 'Failed' }, + { label: 'Passed', value: 'Passed' }, + { label: 'Info', value: 'Info' }, + { label: 'Investigate', value: 'Investigate' }, + ], + helperText: 'Choose which test result statuses trigger an alert.', + } + const returnTypeField = { name: 'ReturnType', label: 'Result Display Type', @@ -1293,6 +1318,20 @@ $md = $summaryTable + "\n\n---\n\n" + $policyTable disabled={isScriptLoading} /> + + + + + From 4b9efd827d27a3498eca51b444934a99e78c634f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 11 May 2026 23:14:49 +0800 Subject: [PATCH 018/164] Update index.js --- src/pages/tenant/reports/list-licenses/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tenant/reports/list-licenses/index.js b/src/pages/tenant/reports/list-licenses/index.js index 35d61ea16158..24a228054f1d 100644 --- a/src/pages/tenant/reports/list-licenses/index.js +++ b/src/pages/tenant/reports/list-licenses/index.js @@ -16,7 +16,7 @@ const Page = () => { "TermInfo", // TODO TermInfo is not showing as a clickable json object in the table, like CApolicies does in the mfa report. IDK how to fix it. -Bobby ]; - return ; + return ; }; Page.getLayout = (page) => {page}; From cfe8c705025c918e34646f68bba815af2a0d04b2 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 11 May 2026 19:51:43 +0200 Subject: [PATCH 019/164] adds #5939 --- .../tenant/reports/list-licenses/index.js | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/pages/tenant/reports/list-licenses/index.js b/src/pages/tenant/reports/list-licenses/index.js index 24a228054f1d..7c2a26c3922f 100644 --- a/src/pages/tenant/reports/list-licenses/index.js +++ b/src/pages/tenant/reports/list-licenses/index.js @@ -1,5 +1,7 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { AssignmentInd } from "@mui/icons-material"; +import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; const Page = () => { const pageTitle = "Licences Report"; @@ -16,7 +18,73 @@ const Page = () => { "TermInfo", // TODO TermInfo is not showing as a clickable json object in the table, like CApolicies does in the mfa report. IDK how to fix it. -Bobby ]; - return ; + const actions = [ + { + label: "Assign License to User", + type: "POST", + url: "/api/ExecBulkLicense", + icon: , + confirmText: "Are you sure you want to assign [License] to the selected user?", + multiPost: false, + children: ({ formHook, row }) => ( + `${option.displayName} (${option.userPrincipalName})`, + valueField: "id", + queryKey: `Users-${row?.Tenant}`, + data: { + Endpoint: "users", + $select: "id,displayName,userPrincipalName", + $count: true, + $orderby: "displayName", + $top: 999, + }, + }} + /> + ), + customDataformatter: (row, action, formData) => ({ + tenantFilter: row.Tenant, + LicenseOperation: "Add", + Licenses: [{ label: row.License, value: row.skuId }], + userIds: [formData.userIds?.value], + }), + }, + ]; + + const offCanvas = { + extendedInfoFields: [ + "Tenant", + "License", + "CountUsed", + "CountAvailable", + "TotalLicenses", + "AssignedUsers", + "AssignedGroups", + "TermInfo", + ], + actions: actions, + }; + + return ( + + ); }; Page.getLayout = (page) => {page}; From 0d42f6798b69367401c33f692b541faa8f483651 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 11 May 2026 19:51:47 +0200 Subject: [PATCH 020/164] #5939 --- .../tenant/reports/list-licenses/index.js | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/src/pages/tenant/reports/list-licenses/index.js b/src/pages/tenant/reports/list-licenses/index.js index 7c2a26c3922f..4d877df75f8f 100644 --- a/src/pages/tenant/reports/list-licenses/index.js +++ b/src/pages/tenant/reports/list-licenses/index.js @@ -1,30 +1,30 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { AssignmentInd } from "@mui/icons-material"; -import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { AssignmentInd } from '@mui/icons-material' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' const Page = () => { - const pageTitle = "Licences Report"; - const apiUrl = "/api/ListLicenses"; + const pageTitle = 'Licences Report' + const apiUrl = '/api/ListLicenses' const simpleColumns = [ - "Tenant", - "License", - "CountUsed", - "CountAvailable", - "TotalLicenses", - "AssignedUsers", - "AssignedGroups", - "TermInfo", // TODO TermInfo is not showing as a clickable json object in the table, like CApolicies does in the mfa report. IDK how to fix it. -Bobby - ]; + 'Tenant', + 'License', + 'CountUsed', + 'CountAvailable', + 'TotalLicenses', + 'AssignedUsers', + 'AssignedGroups', + 'TermInfo', // TODO TermInfo is not showing as a clickable json object in the table, like CApolicies does in the mfa report. IDK how to fix it. -Bobby + ] const actions = [ { - label: "Assign License to User", - type: "POST", - url: "/api/ExecBulkLicense", + label: 'Assign License to User', + type: 'POST', + url: '/api/ExecBulkLicense', icon: , - confirmText: "Are you sure you want to assign [License] to the selected user?", + confirmText: 'Are you sure you want to assign [License] to the selected user?', multiPost: false, children: ({ formHook, row }) => ( { multiple={false} creatable={false} formControl={formHook} - validators={{ required: "Please select a user" }} + validators={{ required: 'Please select a user' }} api={{ tenantFilter: row?.Tenant, - url: "/api/ListGraphRequest", - dataKey: "Results", + url: '/api/ListGraphRequest', + dataKey: 'Results', labelField: (option) => `${option.displayName} (${option.userPrincipalName})`, - valueField: "id", + valueField: 'id', queryKey: `Users-${row?.Tenant}`, data: { - Endpoint: "users", - $select: "id,displayName,userPrincipalName", + Endpoint: 'users', + $select: 'id,displayName,userPrincipalName', $count: true, - $orderby: "displayName", + $orderby: 'displayName', $top: 999, }, }} @@ -54,26 +54,26 @@ const Page = () => { ), customDataformatter: (row, action, formData) => ({ tenantFilter: row.Tenant, - LicenseOperation: "Add", + LicenseOperation: 'Add', Licenses: [{ label: row.License, value: row.skuId }], userIds: [formData.userIds?.value], }), }, - ]; + ] const offCanvas = { extendedInfoFields: [ - "Tenant", - "License", - "CountUsed", - "CountAvailable", - "TotalLicenses", - "AssignedUsers", - "AssignedGroups", - "TermInfo", + 'Tenant', + 'License', + 'CountUsed', + 'CountAvailable', + 'TotalLicenses', + 'AssignedUsers', + 'AssignedGroups', + 'TermInfo', ], actions: actions, - }; + } return ( { actions={actions} offCanvas={offCanvas} /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page From 64e408072e2cea57f098f49acbd84dd3d96187b2 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 11 May 2026 19:58:59 +0200 Subject: [PATCH 021/164] implemenets #5948 --- .../CippApplicationDeployDrawer.jsx | 299 ++++++++++-------- 1 file changed, 159 insertions(+), 140 deletions(-) diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx index 99c2cd52d249..56c7ecae0655 100644 --- a/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -1,119 +1,119 @@ -import React, { useEffect, useCallback, useState } from "react"; -import { Divider, Button, Alert, CircularProgress } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm, useWatch } from "react-hook-form"; -import { Add } from "@mui/icons-material"; -import { CippOffCanvas } from "./CippOffCanvas"; -import CippFormComponent from "./CippFormComponent"; -import { CippFormTenantSelector } from "./CippFormTenantSelector"; -import { CippFormCondition } from "./CippFormCondition"; -import { CippApiResults } from "./CippApiResults"; -import languageList from "../../data/languageList.json"; -import { ApiPostCall } from "../../api/ApiCall"; +import React, { useEffect, useCallback, useState } from 'react' +import { Divider, Button, Alert, CircularProgress } from '@mui/material' +import { Grid } from '@mui/system' +import { useForm, useWatch } from 'react-hook-form' +import { Add } from '@mui/icons-material' +import { CippOffCanvas } from './CippOffCanvas' +import CippFormComponent from './CippFormComponent' +import { CippFormTenantSelector } from './CippFormTenantSelector' +import { CippFormCondition } from './CippFormCondition' +import { CippApiResults } from './CippApiResults' +import languageList from '../../data/languageList.json' +import { ApiPostCall } from '../../api/ApiCall' export const CippApplicationDeployDrawer = ({ - buttonText = "Add Application", + buttonText = 'Add Application', requiredPermissions = [], PermissionButton = Button, }) => { - const [drawerVisible, setDrawerVisible] = useState(false); + const [drawerVisible, setDrawerVisible] = useState(false) const formControl = useForm({ - mode: "onChange", - }); + mode: 'onChange', + }) const selectedTenants = useWatch({ control: formControl.control, - name: "selectedTenants", - }); + name: 'selectedTenants', + }) const applicationType = useWatch({ control: formControl.control, - name: "appType", - }); + name: 'appType', + }) const searchQuerySelection = useWatch({ control: formControl.control, - name: "packageSearch", - }); + name: 'packageSearch', + }) const updateSearchSelection = useCallback( (searchQuerySelection) => { if (searchQuerySelection) { - formControl.setValue("packagename", searchQuerySelection.value.packagename); - formControl.setValue("applicationName", searchQuerySelection.value.applicationName); - formControl.setValue("description", searchQuerySelection.value.description); + formControl.setValue('packagename', searchQuerySelection.value.packagename) + formControl.setValue('applicationName', searchQuerySelection.value.applicationName) + formControl.setValue('description', searchQuerySelection.value.description) searchQuerySelection.value.customRepo - ? formControl.setValue("customRepo", searchQuerySelection.value.customRepo) - : null; + ? formControl.setValue('customRepo', searchQuerySelection.value.customRepo) + : null } }, - [formControl.setValue], - ); + [formControl.setValue] + ) useEffect(() => { - updateSearchSelection(searchQuerySelection); - }, [updateSearchSelection, searchQuerySelection]); + updateSearchSelection(searchQuerySelection) + }, [updateSearchSelection, searchQuerySelection]) const postUrl = { - mspApp: "/api/AddMSPApp", - StoreApp: "/api/AddStoreApp", - winGetApp: "/api/AddwinGetApp", - chocolateyApp: "/api/AddChocoApp", - officeApp: "/api/AddOfficeApp", - win32ScriptApp: "/api/AddWin32ScriptApp", - }; + mspApp: '/api/AddMSPApp', + StoreApp: '/api/AddStoreApp', + winGetApp: '/api/AddwinGetApp', + chocolateyApp: '/api/AddChocoApp', + officeApp: '/api/AddOfficeApp', + win32ScriptApp: '/api/AddWin32ScriptApp', + } const ChocosearchResults = ApiPostCall({ urlFromData: true, - }); + }) const winGetSearchResults = ApiPostCall({ urlFromData: true, - }); + }) const deployApplication = ApiPostCall({ urlFromData: true, - relatedQueryKeys: ["Queued Applications"], - }); + relatedQueryKeys: ['Queued Applications'], + }) const searchApp = (searchText, type) => { - if (type === "choco") { + if (type === 'choco') { ChocosearchResults.mutate({ url: `/api/ListAppsRepository`, data: { search: searchText }, queryKey: `SearchApp-${searchText}-${type}`, - }); + }) } - if (type === "StoreApp") { + if (type === 'StoreApp') { winGetSearchResults.mutate({ url: `/api/ListPotentialApps`, - data: { searchString: searchText, type: "WinGet" }, + data: { searchString: searchText, type: 'WinGet' }, queryKey: `SearchApp-${searchText}-${type}`, - }); + }) } - }; + } const handleSubmit = () => { - const formData = formControl.getValues(); - const formattedData = { ...formData }; - formattedData.tenantFilter = "allTenants"; //added to prevent issues with location check. temp fix + const formData = formControl.getValues() + const formattedData = { ...formData } + formattedData.tenantFilter = 'allTenants' //added to prevent issues with location check. temp fix formattedData.selectedTenants = selectedTenants.map((tenant) => ({ defaultDomainName: tenant.value, customerId: tenant.addedFields.customerId, - })); + })) deployApplication.mutate({ url: postUrl[applicationType?.value], data: formattedData, - relatedQueryKeys: ["Queued Applications"], - }); - }; + relatedQueryKeys: ['Queued Applications'], + }) + } const handleCloseDrawer = () => { - setDrawerVisible(false); - formControl.reset(); - }; + setDrawerVisible(false) + formControl.reset() + } return ( <> @@ -130,7 +130,7 @@ export const CippApplicationDeployDrawer = ({ onClose={handleCloseDrawer} size="xl" footer={ -
+
@@ -175,7 +174,7 @@ const Page = () => { )}
- ); + ) return ( { actions={actions} tableFilter={tableFilter} simpleColumns={[ - "templateName", - "type", - "tenantFilter", - "excludedTenants", - "updatedAt", - "updatedBy", - "runManually", - "standards", + 'templateName', + 'type', + 'tenantFilter', + 'excludedTenants', + 'updatedAt', + 'updatedBy', + 'runManually', + 'standards', ]} queryKey="listStandardTemplates" /> - ); -}; + ) +} Page.getLayout = (page) => ( {page} -); +) -export default Page; +export default Page diff --git a/src/pages/tenant/standards/templates/template.jsx b/src/pages/tenant/standards/templates/template.jsx index 3890212fdbd8..630fdee6f2ce 100644 --- a/src/pages/tenant/standards/templates/template.jsx +++ b/src/pages/tenant/standards/templates/template.jsx @@ -1,205 +1,205 @@ -import { Box, Button, Container, Stack, Typography, SvgIcon, Skeleton } from "@mui/material"; -import { Grid } from "@mui/system"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { useForm, useWatch } from "react-hook-form"; -import { useRouter } from "next/router"; -import { Add, SaveRounded } from "@mui/icons-material"; -import { useEffect, useState, useCallback, useMemo, useRef, lazy, Suspense } from "react"; -import standards from "../../../../data/standards"; -import CippStandardAccordion from "../../../../components/CippStandards/CippStandardAccordion"; +import { Box, Button, Container, Stack, Typography, SvgIcon, Skeleton } from '@mui/material' +import { Grid } from '@mui/system' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { useForm, useWatch } from 'react-hook-form' +import { useRouter } from 'next/router' +import { Add, SaveRounded } from '@mui/icons-material' +import { useEffect, useState, useCallback, useMemo, useRef, lazy, Suspense } from 'react' +import standards from '../../../../data/standards' +import CippStandardAccordion from '../../../../components/CippStandards/CippStandardAccordion' // Lazy load the dialog to improve initial page load performance const CippStandardDialog = lazy( - () => import("../../../../components/CippStandards/CippStandardDialog"), -); -import CippStandardsSideBar from "../../../../components/CippStandards/CippStandardsSideBar"; -import { ArrowLeftIcon } from "@mui/x-date-pickers"; -import { useDialog } from "../../../../hooks/use-dialog"; -import { ApiGetCall } from "../../../../api/ApiCall"; -import _ from "lodash"; -import { createDriftManagementActions } from "../../manage/driftManagementActions"; -import { ActionsMenu } from "../../../../components/actions-menu"; -import { useSettings } from "../../../../hooks/use-settings"; -import { CippHead } from "../../../../components/CippComponents/CippHead"; + () => import('../../../../components/CippStandards/CippStandardDialog') +) +import CippStandardsSideBar from '../../../../components/CippStandards/CippStandardsSideBar' +import { ArrowLeftIcon } from '@mui/x-date-pickers' +import { useDialog } from '../../../../hooks/use-dialog' +import { ApiGetCall } from '../../../../api/ApiCall' +import _ from 'lodash' +import { createDriftManagementActions } from '../../manage/driftManagementActions' +import { ActionsMenu } from '../../../../components/actions-menu' +import { useSettings } from '../../../../hooks/use-settings' +import { CippHead } from '../../../../components/CippComponents/CippHead' const Page = () => { - const router = useRouter(); - const [editMode, setEditMode] = useState(false); - const formControl = useForm({ mode: "onBlur" }); - const { formState } = formControl; - const [dialogOpen, setDialogOpen] = useState(false); - const [expanded, setExpanded] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedStandards, setSelectedStandards] = useState({}); - const [updatedAt, setUpdatedAt] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [currentStep, setCurrentStep] = useState(0); - const [hasDriftConflict, setHasDriftConflict] = useState(false); - const initialStandardsRef = useRef({}); - - const currentTenant = useSettings().currentTenant; + const router = useRouter() + const [editMode, setEditMode] = useState(false) + const formControl = useForm({ mode: 'onBlur' }) + const { formState } = formControl + const [dialogOpen, setDialogOpen] = useState(false) + const [expanded, setExpanded] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [selectedStandards, setSelectedStandards] = useState({}) + const [updatedAt, setUpdatedAt] = useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [currentStep, setCurrentStep] = useState(0) + const [hasDriftConflict, setHasDriftConflict] = useState(false) + const initialStandardsRef = useRef({}) + + const currentTenant = useSettings().currentTenant // Check if this is drift mode - const isDriftMode = router.query.type === "drift"; + const isDriftMode = router.query.type === 'drift' // Set drift mode flag in form when in drift mode useEffect(() => { if (isDriftMode) { - formControl.setValue("isDriftTemplate", true); + formControl.setValue('isDriftTemplate', true) } - }, [isDriftMode, formControl]); + }, [isDriftMode, formControl]) // Watch form values to check valid configuration - const watchForm = useWatch({ control: formControl.control }); + const watchForm = useWatch({ control: formControl.control }) const existingTemplate = ApiGetCall({ url: `/api/listStandardTemplates`, data: { id: router.query.id }, queryKey: `listStandardTemplates-${router.query.id}`, waiting: editMode, - }); + }) // Check if the template configuration is valid and update currentStep useEffect(() => { const stepsStatus = { - step1: !!_.get(watchForm, "templateName"), - step2: _.get(watchForm, "tenantFilter", []).length > 0, + step1: !!_.get(watchForm, 'templateName'), + step2: _.get(watchForm, 'tenantFilter', []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - _.get(watchForm, "standards") && + _.get(watchForm, 'standards') && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, standardName, {}); + const standardValues = _.get(watchForm, standardName, {}) // Always require an action value which should be an array with at least one element - const actionValue = _.get(standardValues, "action"); - return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); + const actionValue = _.get(standardValues, 'action') + return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0) }), - }; + } - const completedSteps = Object.values(stepsStatus).filter(Boolean).length; - setCurrentStep(completedSteps); - }, [selectedStandards, watchForm, isDriftMode]); + const completedSteps = Object.values(stepsStatus).filter(Boolean).length + setCurrentStep(completedSteps) + }, [selectedStandards, watchForm, isDriftMode]) // Handle route change events const handleRouteChange = useCallback( (url) => { if (hasUnsavedChanges) { const confirmLeave = window.confirm( - "You have unsaved changes. Are you sure you want to leave this page?", - ); + 'You have unsaved changes. Are you sure you want to leave this page?' + ) if (!confirmLeave) { - router.events.emit("routeChangeError"); - throw "Route change was aborted"; + router.events.emit('routeChangeError') + throw 'Route change was aborted' } } }, - [hasUnsavedChanges, router], - ); + [hasUnsavedChanges, router] + ) // Handle browser back/forward navigation or tab close useEffect(() => { const handleBeforeUnload = (e) => { if (hasUnsavedChanges) { - e.preventDefault(); - e.returnValue = "You have unsaved changes. Are you sure you want to leave this page?"; - return e.returnValue; + e.preventDefault() + e.returnValue = 'You have unsaved changes. Are you sure you want to leave this page?' + return e.returnValue } - }; + } // Add event listeners - window.addEventListener("beforeunload", handleBeforeUnload); - router.events.on("routeChangeStart", handleRouteChange); + window.addEventListener('beforeunload', handleBeforeUnload) + router.events.on('routeChangeStart', handleRouteChange) // Remove event listeners on cleanup return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - router.events.off("routeChangeStart", handleRouteChange); - }; - }, [hasUnsavedChanges, handleRouteChange, router.events]); + window.removeEventListener('beforeunload', handleBeforeUnload) + router.events.off('routeChangeStart', handleRouteChange) + } + }, [hasUnsavedChanges, handleRouteChange, router.events]) // Track form changes useEffect(() => { // Compare the current form values with the initial values to check for real changes - const currentValues = formControl.getValues(); - const initialValues = initialStandardsRef.current; + const currentValues = formControl.getValues() + const initialValues = initialStandardsRef.current if ( formState.isDirty || JSON.stringify(selectedStandards) !== JSON.stringify(initialStandardsRef.current) ) { - setHasUnsavedChanges(true); + setHasUnsavedChanges(true) } else { - setHasUnsavedChanges(false); + setHasUnsavedChanges(false) } - }, [formState.isDirty, selectedStandards, formControl]); + }, [formState.isDirty, selectedStandards, formControl]) useEffect(() => { if (router.query.id) { - setEditMode(true); + setEditMode(true) } if (existingTemplate.isSuccess) { //formControl.reset(existingTemplate.data?.[0]); - const apiData = existingTemplate.data?.[0]; + const apiData = existingTemplate.data?.[0] Object.keys(apiData.standards).forEach((key) => { if (Array.isArray(apiData.standards[key])) { apiData.standards[key] = apiData.standards[key].filter( - (value) => value !== null && value !== undefined, - ); + (value) => value !== null && value !== undefined + ) } - }); + }) - formControl.reset(apiData); + formControl.reset(apiData) if (router.query.clone) { - formControl.setValue("templateName", `${apiData.templateName} (Clone)`); - formControl.setValue("GUID", ""); + formControl.setValue('templateName', `${apiData.templateName} (Clone)`) + formControl.setValue('GUID', '') } //set the updated at date and user setUpdatedAt({ date: apiData?.updatedAt, user: apiData?.updatedBy, - }); + }) // Transform standards from the API to match the format for selectedStandards - const standardsFromApi = apiData?.standards; - const transformedStandards = {}; + const standardsFromApi = apiData?.standards + const transformedStandards = {} Object.keys(standardsFromApi).forEach((key) => { if (Array.isArray(standardsFromApi[key])) { standardsFromApi[key].forEach((_, index) => { - transformedStandards[`standards.${key}[${index}]`] = true; - }); + transformedStandards[`standards.${key}[${index}]`] = true + }) } else { - transformedStandards[`standards.${key}`] = true; + transformedStandards[`standards.${key}`] = true } - }); + }) - setSelectedStandards(transformedStandards); + setSelectedStandards(transformedStandards) // Store initial state for change detection - initialStandardsRef.current = { ...transformedStandards }; - setHasUnsavedChanges(false); + initialStandardsRef.current = { ...transformedStandards } + setHasUnsavedChanges(false) } - }, [existingTemplate.isSuccess, router]); + }, [existingTemplate.isSuccess, router]) // Memoize categories to avoid unnecessary recalculations const categories = useMemo(() => { return standards.reduce((acc, standard) => { - const { cat } = standard; + const { cat } = standard if (!acc[cat]) { - acc[cat] = []; + acc[cat] = [] } - acc[cat].push(standard); - return acc; - }, {}); - }, []); + acc[cat].push(standard) + return acc + }, {}) + }, []) const handleOpenDialog = useCallback(() => { - setDialogOpen(true); - }, []); + setDialogOpen(true) + }, []) const handleCloseDialog = useCallback(() => { - setDialogOpen(false); - setSearchQuery(""); - }, []); + setDialogOpen(false) + setSearchQuery('') + }, []) const filterStandards = (standardsList) => standardsList.filter( @@ -211,149 +211,157 @@ const Page = () => { (standard.appliesToTest && standard.appliesToTest.some((testId) => testId.toLowerCase().includes(searchQuery.toLowerCase()) - )), - ); + )) + ) const handleToggleStandard = (standardName) => { setSelectedStandards((prev) => ({ ...prev, [standardName]: !prev[standardName], - })); - }; + })) + } const handleAddMultipleStandard = (standardName) => { //if the standardname contains an array qualifier,e.g standardName[0], strip that away. - const arrayPattern = /(.*)\[(\d+)\]$/; - const match = standardName.match(arrayPattern); + const arrayPattern = /(.*)\[(\d+)\]$/ + const match = standardName.match(arrayPattern) if (match) { - standardName = match[1]; + standardName = match[1] } setSelectedStandards((prev) => { - const existingInstances = Object.keys(prev).filter((name) => name.startsWith(standardName)); - const newIndex = existingInstances.length; + const existingInstances = Object.keys(prev).filter((name) => name.startsWith(standardName)) + const newIndex = existingInstances.length return { ...prev, [`${standardName}[${newIndex}]`]: true, - }; - }); - }; + } + }) + } const handleRemoveStandard = (standardName) => { - const arrayPattern = /(.*)\[(\d+)\]$/; - const match = standardName.match(arrayPattern); + const arrayPattern = /(.*)\[(\d+)\]$/ + const match = standardName.match(arrayPattern) if (match) { - const baseName = match[1]; - const removedIndex = parseInt(match[2]); + const baseName = match[1] + const removedIndex = parseInt(match[2]) // Remove the item from the form array - const currentArray = formControl.getValues(baseName) || []; - const updatedArray = currentArray.filter((_, i) => i !== removedIndex); - formControl.setValue(baseName, updatedArray); + const currentArray = formControl.getValues(baseName) || [] + const updatedArray = currentArray.filter((_, i) => i !== removedIndex) + formControl.setValue(baseName, updatedArray) // Re-index selectedStandards to keep indices contiguous - const escapedBaseName = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const reindexPattern = new RegExp(`^${escapedBaseName}\\[(\\d+)\\]$`); + const escapedBaseName = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const reindexPattern = new RegExp(`^${escapedBaseName}\\[(\\d+)\\]$`) setSelectedStandards((prev) => { - const newSelected = {}; + const newSelected = {} Object.keys(prev).forEach((key) => { - const keyMatch = key.match(reindexPattern); + const keyMatch = key.match(reindexPattern) if (keyMatch) { - const idx = parseInt(keyMatch[1]); + const idx = parseInt(keyMatch[1]) if (idx < removedIndex) { - newSelected[key] = prev[key]; + newSelected[key] = prev[key] } else if (idx > removedIndex) { // Shift higher indices down by 1 - newSelected[`${baseName}[${idx - 1}]`] = prev[key]; + newSelected[`${baseName}[${idx - 1}]`] = prev[key] } // Skip the removed index } else { - newSelected[key] = prev[key]; + newSelected[key] = prev[key] } - }); - return newSelected; - }); + }) + return newSelected + }) } else { setSelectedStandards((prev) => { - const newSelected = { ...prev }; - delete newSelected[standardName]; - return newSelected; - }); - formControl.unregister(standardName); + const newSelected = { ...prev } + delete newSelected[standardName] + return newSelected + }) + formControl.unregister(standardName) } - }; + } const handleAccordionToggle = (standardName) => { - setExpanded((prev) => (prev === standardName ? null : standardName)); - }; + setExpanded((prev) => (prev === standardName ? null : standardName)) + } - const createDialog = useDialog(); + const createDialog = useDialog() // Save action that will open the create dialog const handleSave = () => { - createDialog.handleOpen(); + createDialog.handleOpen() // Will be set to false after successful save in the dialog component - }; + } // Determine if save button should be disabled based on configuration const isSaveDisabled = isDriftMode - ? !_.get(watchForm, "tenantFilter") || - !_.get(watchForm, "tenantFilter").length || + ? !_.get(watchForm, 'tenantFilter') || + !_.get(watchForm, 'tenantFilter').length || currentStep < 4 || hasDriftConflict // For drift mode, require all steps and no drift conflicts - : !_.get(watchForm, "tenantFilter") || - !_.get(watchForm, "tenantFilter").length || - currentStep < 4; + : !_.get(watchForm, 'tenantFilter') || + !_.get(watchForm, 'tenantFilter').length || + currentStep < 4 // Create drift management actions (excluding refresh) const driftActions = useMemo(() => { - if (!editMode || !router.query.id) return []; + if (!editMode || !router.query.id) return [] const allActions = createDriftManagementActions({ templateId: router.query.id, - onRefresh: () => {}, // Empty function since we're filtering out refresh + onRefresh: () => {}, currentTenant: currentTenant, - }); + templateTenants: Array.isArray(watchForm?.tenantFilter) ? watchForm.tenantFilter : [], + excludedTenants: Array.isArray(watchForm?.excludedTenants) ? watchForm.excludedTenants : [], + }) // Filter out the refresh action - return allActions.filter((action) => action.label !== "Refresh Data"); - }, [editMode, router.query.id, currentTenant]); + return allActions.filter((action) => action.label !== 'Refresh Data') + }, [ + editMode, + router.query.id, + currentTenant, + watchForm?.tenantFilter, + watchForm?.excludedTenants, + ]) - const actions = []; + const actions = [] const steps = [ - "Set a name for the Template", - "Assigned Template to Tenants", - "Added Standards to Template", - "Configured all Standards", - ]; + 'Set a name for the Template', + 'Assigned Template to Tenants', + 'Added Standards to Template', + 'Configured all Standards', + ] const handleSafeNavigation = (url) => { if (hasUnsavedChanges) { const confirmLeave = window.confirm( - "You have unsaved changes. Are you sure you want to leave this page?", - ); + 'You have unsaved changes. Are you sure you want to leave this page?' + ) if (confirmLeave) { - router.push(url); + router.push(url) } } else { - router.push(url); + router.push(url) } - }; + } return ( - + @@ -368,11 +376,11 @@ const Page = () => { {editMode ? isDriftMode - ? "Edit Drift Template" - : "Edit Standards Template" + ? 'Edit Drift Template' + : 'Edit Standards Template' : isDriftMode - ? "Add Drift Template" - : "Add Standards Template"} + ? 'Add Drift Template' + : 'Add Standards Template'} + ) : isSuccess ? ( + + ) : isError ? ( + + ) : null} + +
+ ) +} diff --git a/src/components/CippComponents/SsoMigrationDialog.jsx b/src/components/CippComponents/SsoMigrationDialog.jsx new file mode 100644 index 000000000000..fe184c40cd72 --- /dev/null +++ b/src/components/CippComponents/SsoMigrationDialog.jsx @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Switch, + Typography, +} from '@mui/material' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' + +const DISMISS_KEY = 'cipp_sso_migration_dismissed' + +export const SsoMigrationDialog = () => { + const [open, setOpen] = useState(false) + const [multiTenant, setMultiTenant] = useState(false) + const [submitted, setSubmitted] = useState(false) + + const currentRole = ApiGetCall({ + url: '/api/me', + queryKey: 'authmecipp', + }) + + const ssoSetup = ApiPostCall({ + relatedQueryKeys: 'authmecipp', + }) + + const permissions = currentRole.data?.permissions || [] + const ssoMigration = currentRole.data?.ssoMigration + const hasPermission = permissions.includes('CIPP.AppSettings.ReadWrite') + + useEffect(() => { + if (!currentRole.isSuccess || !hasPermission || !ssoMigration) return + if (ssoMigration.status !== 'none') return + + const dismissed = localStorage.getItem(DISMISS_KEY) + if (dismissed === 'true') return + + setOpen(true) + }, [currentRole.isSuccess, hasPermission, ssoMigration]) + + const handleApprove = useCallback(() => { + setSubmitted(true) + ssoSetup.mutate({ + url: '/api/ExecSSOSetup', + data: { + Action: 'Create', + multiTenant, + }, + }) + }, [multiTenant, ssoSetup]) + + const handleDismiss = useCallback(() => { + localStorage.setItem(DISMISS_KEY, 'true') + setOpen(false) + }, []) + + const handleClose = useCallback(() => { + setOpen(false) + }, []) + + const result = ssoSetup.data?.data?.Results ?? ssoSetup.data?.Results + const isSuccess = result?.severity === 'success' + const isError = ssoSetup.isError || result?.severity === 'failed' + + return ( + + Prepare for CIPP Single Sign-On + + {!submitted ? ( + <> + + CIPP will soon be moving to a dedicated Single Sign-On model, giving you full control + over Conditional Access policies, MFA requirements, and session management for your + CIPP users. + + + To get ready, CIPP needs to create an app registration in your tenant called{' '} + CIPP-SSO with minimal permissions (OpenID, Profile, Email only). + This won't change how you log in today — it just prepares your tenant for when + the update rolls out. + + + Review the options below and click "Create App Registration" to get set up + ahead of time. + + + setMultiTenant(e.target.checked)} /> + } + label="Multi-tenant mode (allow users from multiple Entra ID tenants to log in)" + sx={{ mb: 1 }} + /> + + ) : ssoSetup.isPending ? ( + <> + + Creating CIPP-SSO app registration... + + ) : isSuccess ? ( + + {result.message} + + ) : isError ? ( + + {result?.message || ssoSetup.error?.message || 'SSO setup failed. It will be retried automatically.'} + + ) : null} + + + {!submitted ? ( + <> + + + + ) : ( + + )} + + + ) +} diff --git a/src/components/CippIntegrations/CippApiClientManagement.jsx b/src/components/CippIntegrations/CippApiClientManagement.jsx index 3dab6bf2bf1b..a9a2d2960ef1 100644 --- a/src/components/CippIntegrations/CippApiClientManagement.jsx +++ b/src/components/CippIntegrations/CippApiClientManagement.jsx @@ -1,6 +1,7 @@ import { Button, Stack, SvgIcon, Menu, MenuItem, ListItemText, Alert } from "@mui/material"; -import { useState } from "react"; +import { useState, useEffect, useMemo } from "react"; import isEqual from "lodash/isEqual"; +import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { ApiGetCall, ApiGetCallWithPagination, ApiPostCall } from "../../api/ApiCall"; import { CippDataTable } from "../CippTable/CippDataTable"; @@ -19,6 +20,7 @@ import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; import { Box } from "@mui/system"; const CippApiClientManagement = () => { + const router = useRouter(); const [openAddClientDialog, setOpenAddClientDialog] = useState(false); const [openAddExistingAppDialog, setOpenAddExistingAppDialog] = useState(false); const [addClientRetryPayload, setAddClientRetryPayload] = useState(null); @@ -45,6 +47,46 @@ const CippApiClientManagement = () => { queryKey: "ApiClients", }); + const hasUnsavedChanges = useMemo(() => { + if (!azureConfig.isSuccess || !apiClients.isSuccess) return false; + return !isEqual( + (apiClients.data?.pages?.[0]?.Results || []) + .filter((c) => c.Enabled) + .map((c) => c.ClientId) + .sort(), + (azureConfig.data?.Results?.ClientIDs || []).sort() + ); + }, [azureConfig.isSuccess, azureConfig.data, apiClients.isSuccess, apiClients.data]); + + useEffect(() => { + const handleBeforeUnload = (e) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ""; + } + }; + + const handleRouteChange = (url) => { + if ( + hasUnsavedChanges && + !window.confirm( + "You have unsaved API client changes. Are you sure you want to leave this page?" + ) + ) { + router.events.emit("routeChangeError"); + throw "Route change aborted due to unsaved changes."; + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + router.events.on("routeChangeStart", handleRouteChange); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + router.events.off("routeChangeStart", handleRouteChange); + }; + }, [hasUnsavedChanges, router.events]); + const handleMenuOpen = (event) => { setMenuAnchorEl(event.currentTarget); }; @@ -54,11 +96,18 @@ const CippApiClientManagement = () => { }; const handleSaveToAzure = () => { + handleMenuClose(); + if ( + !window.confirm( + "Saving to Azure will restart the CIPP instance. Changes may take up to 60 seconds to reflect. Do you want to continue?" + ) + ) { + return; + } postCall.mutate({ url: `/api/ExecApiClient?action=SaveToAzure`, data: {}, }); - handleMenuClose(); }; const getRetryPayload = (result) => { @@ -284,13 +333,7 @@ const CippApiClientManagement = () => { /> {azureConfig.isSuccess && apiClients.isSuccess && ( <> - {!isEqual( - (apiClients.data?.pages?.[0]?.Results || []) - .filter((c) => c.Enabled) - .map((c) => c.ClientId) - .sort(), - (azureConfig.data?.Results?.ClientIDs || []).sort() - ) && ( + {hasUnsavedChanges && ( You have unsaved changes. Click Actions > Save Azure Configuration to update diff --git a/src/components/CippSettings/CippContainerManagement.jsx b/src/components/CippSettings/CippContainerManagement.jsx new file mode 100644 index 000000000000..526051233c70 --- /dev/null +++ b/src/components/CippSettings/CippContainerManagement.jsx @@ -0,0 +1,223 @@ +import { useEffect } from "react"; +import { + Alert, + Button, + CardActions, + CardContent, + Chip, + Divider, + Skeleton, + Stack, + Typography, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { CippApiResults } from "../CippComponents/CippApiResults"; + +const channelLabels = { + latest: { label: "Latest (Stable)", color: "success" }, + dev: { label: "Dev", color: "warning" }, + nightly: { label: "Nightly", color: "info" }, + unknown: { label: "Unknown", color: "default" }, +}; + +export const CippContainerManagement = () => { + const formControl = useForm({ + mode: "onChange", + defaultValues: { Channel: null }, + }); + + const containerStatus = ApiGetCall({ + url: "/api/ExecContainerManagement", + data: { Action: "Status" }, + queryKey: "containerStatus", + }); + + const containerAction = ApiPostCall({ + relatedQueryKeys: ["containerStatus"], + }); + + const data = containerStatus.data?.Results; + const channelInfo = channelLabels[data?.CurrentChannel] ?? channelLabels.unknown; + + const channelOptions = (data?.ValidChannels ?? ["latest", "dev", "nightly"]).map((c) => ({ + label: channelLabels[c]?.label ?? c, + value: c, + })); + + useEffect(() => { + if (containerStatus.isSuccess && data?.CurrentChannel) { + const current = channelOptions.find((o) => o.value === data.CurrentChannel); + if (current) { + formControl.reset({ Channel: current }); + } + } + }, [containerStatus.isSuccess, data?.CurrentChannel]); + + const handleUpdateChannel = () => { + const selected = formControl.getValues("Channel"); + const channel = selected?.value ?? selected; + containerAction.mutate({ + url: "/api/ExecContainerManagement", + data: { Action: "UpdateChannel", Channel: channel }, + }); + }; + + const handleRestart = () => { + containerAction.mutate({ + url: "/api/ExecContainerManagement", + data: { Action: "Restart" }, + }); + }; + + return ( + + + + {containerStatus.isLoading ? ( + + + + + ) : ( + + {data?.ConfiguredChannel && data.ConfiguredChannel !== data.CurrentChannel && ( + + A channel change is pending. Running: {data.CurrentChannel}, + configured: {data.ConfiguredChannel}. Restart the container to + apply. + + )} + + + + Running Channel + + + + + + + + + Image Tag + + + + + {data?.ImageTag ?? "unknown"} + + + + + + App Version + + + + + {data?.CurrentVersion ?? "unknown"} + + + + + + Commit SHA + + + + + {data?.CommitSha ?? "unknown"} + + + + {data?.CurrentImage && data.CurrentImage !== "unknown" && ( + <> + + + Container Image + + + + + {data.CurrentImage} + + + + )} + + {data?.SiteName && ( + <> + + + App Service + + + + {data.SiteName} + + + )} + + + )} + + + + + + + + Changing the release channel updates the container image tag. The new image will be + pulled on the next container restart. Switching to "Dev" or + "Nightly" may include unstable or untested changes. + + + + + + + + + + + + + + Restart the application container. This will cause a brief downtime while the container + restarts. If you changed the release channel, this will pull the new image. + + + + + + + + ); +}; + +export default CippContainerManagement; diff --git a/src/components/CippSettings/CippSSOSettings.jsx b/src/components/CippSettings/CippSSOSettings.jsx new file mode 100644 index 000000000000..e22cd1ab6edf --- /dev/null +++ b/src/components/CippSettings/CippSSOSettings.jsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from "react"; +import { + Alert, + Button, + CardActions, + CardContent, + CardHeader, + Chip, + Divider, + Skeleton, + Stack, + Typography, +} from "@mui/material"; +import { useForm } from "react-hook-form"; +import { Grid } from "@mui/system"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { CippApiResults } from "../CippComponents/CippApiResults"; + +const statusLabels = { + none: { label: "Not Configured", color: "default" }, + app_created: { label: "App Created", color: "info" }, + appid_stored: { label: "App ID Stored", color: "info" }, + secrets_stored: { label: "Secrets Stored", color: "success" }, + complete: { label: "Complete", color: "success" }, + error: { label: "Error", color: "error" }, +}; + +export const CippSSOSettings = () => { + const [showCreate, setShowCreate] = useState(false); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { multiTenant: false }, + }); + + const ssoStatus = ApiGetCall({ + url: "/api/ExecSSOSetup", + data: { Action: "Status" }, + queryKey: "SSOStatus", + }); + + const ssoAction = ApiPostCall({ + relatedQueryKeys: ["SSOStatus", "authmecipp"], + }); + + useEffect(() => { + if (ssoStatus.isSuccess && ssoStatus.data?.Results) { + const data = ssoStatus.data.Results; + formControl.reset({ multiTenant: data.multiTenant ?? false }); + setShowCreate(!data.configured); + } + }, [ssoStatus.isSuccess, ssoStatus.data]); + + const handleUpdate = () => { + if ( + !window.confirm( + "Updating SSO settings will restart the CIPP instance. Changes may take up to 60 seconds to reflect. Do you want to continue?" + ) + ) { + return; + } + ssoAction.mutate({ + url: "/api/ExecSSOSetup", + data: { + Action: "Update", + multiTenant: formControl.getValues("multiTenant"), + }, + }); + }; + + const handleCreate = () => { + ssoAction.mutate({ + url: "/api/ExecSSOSetup", + data: { + Action: "Create", + multiTenant: formControl.getValues("multiTenant"), + }, + }); + }; + + const handleRotateSecret = () => { + ssoAction.mutate({ + url: "/api/ExecSSOSetup", + data: { Action: "RotateSecret" }, + }); + }; + + const data = ssoStatus.data?.Results; + const statusInfo = statusLabels[data?.status] ?? statusLabels.none; + + return ( + + + {ssoStatus.isLoading ? ( + + + + + ) : ( + + + + + Status + + + + + + + {data?.appId && ( + <> + + + App ID + + + + + {data.appId} + + + + )} + + {data?.createdAt && ( + <> + + + Created + + + + + {new Date(data.createdAt).toLocaleString()} + + + + )} + + {data?.lastError && ( + <> + + + {data.lastError} + + + + )} + + + + + + + + + )} + + {!ssoStatus.isLoading && ( + + + {showCreate ? ( + + ) : ( + <> + + + + )} + + + )} + + ); +}; diff --git a/src/components/CippSettings/CippUserManagement.jsx b/src/components/CippSettings/CippUserManagement.jsx new file mode 100644 index 000000000000..ab4be74c1b2b --- /dev/null +++ b/src/components/CippSettings/CippUserManagement.jsx @@ -0,0 +1,221 @@ +import React, { useState } from "react"; +import { + Alert, + Box, + Button, + CardActions, + CardContent, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import { TrashIcon, PlusIcon, PencilIcon } from "@heroicons/react/24/outline"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippApiResults } from "../CippComponents/CippApiResults"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; + +export const CippUserManagement = () => { + const [dialogOpen, setDialogOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { UPN: "", Roles: [] }, + }); + + const rolesQuery = ApiGetCall({ + url: "/api/ListCustomRole", + queryKey: "customRoleList", + }); + + const userAction = ApiPostCall({ + relatedQueryKeys: ["cippUsersList"], + }); + + const allRoles = Array.isArray(rolesQuery.data) ? rolesQuery.data : []; + const roleOptions = allRoles.map((r) => ({ + label: `${r.RoleName} (${r.Type})`, + value: r.RoleName, + })); + + const openAddDialog = () => { + setEditingUser(null); + formControl.reset({ UPN: "", Roles: [] }); + setDialogOpen(true); + }; + + const openEditDialog = (row) => { + setEditingUser(row); + const currentRoles = (row.Roles ?? []).map((r) => { + const match = roleOptions.find((opt) => opt.value === r); + return match ?? { label: r, value: r }; + }); + formControl.reset({ UPN: row.UPN, Roles: currentRoles }); + setDialogOpen(true); + }; + + const handleSaveUser = (data) => { + const roles = Array.isArray(data.Roles) ? data.Roles.map((r) => r.value ?? r) : [data.Roles]; + userAction.mutate( + { + url: "/api/ExecCIPPUsers", + data: { + Action: "AddUpdate", + UPN: data.UPN, + Roles: roles, + }, + }, + { + onSuccess: () => { + formControl.reset({ UPN: "", Roles: [] }); + setEditingUser(null); + setDialogOpen(false); + }, + } + ); + }; + + const actions = [ + { + label: "Edit Roles", + icon: ( + + + + ), + noConfirm: true, + customFunction: (row) => openEditDialog(row), + }, + { + label: "Delete User", + icon: ( + + + + ), + confirmText: "Are you sure you want to remove this user's access to CIPP?", + url: "/api/ExecCIPPUsers", + type: "POST", + data: { + Action: "Delete", + UPN: "UPN", + }, + relatedQueryKeys: ["cippUsersList"], + }, + ]; + + const offCanvas = { + children: (row) => ( + + + + Email / UPN + + {row.UPN} + + + + + Assigned Roles + + + {(row.Roles ?? []).map((role, idx) => ( + + ))} + + + + ), + }; + + return ( + + + + + } + onClick={openAddDialog} + > + Add User + + } + api={{ + url: "/api/ListCIPPUsers", + dataKey: "Users", + }} + queryKey="cippUsersList" + simpleColumns={["UPN", "Roles"]} + offCanvas={offCanvas} + /> + + + + setDialogOpen(false)} + maxWidth="sm" + fullWidth + > + {editingUser ? `Edit Roles — ${editingUser.UPN}` : "Add CIPP User"} + + + + {editingUser + ? "Update the roles assigned to this user." + : "Add a user by their email address (UPN) and assign one or more roles. If the user already exists, their roles will be updated."} + + {!editingUser && ( + + )} + + + + + + + + + + ); +}; + +export default CippUserManagement; diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index a085b528b45c..031f363c4dac 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -1,6 +1,8 @@ +import { useMemo } from 'react' import { usePathname, useRouter } from 'next/navigation' import { Box, Divider, Stack, Tab, Tabs } from '@mui/material' import { useSearchParams } from 'next/navigation' +import { ApiGetCall } from '../api/ApiCall' export const TabbedLayout = (props) => { const { tabOptions, children } = props @@ -8,6 +10,25 @@ export const TabbedLayout = (props) => { const pathname = usePathname() const searchParams = useSearchParams() + const featureFlags = ApiGetCall({ + url: '/api/ListFeatureFlags', + queryKey: 'featureFlags', + staleTime: 600000, + }) + + const visibleTabs = useMemo(() => { + if (!featureFlags.isSuccess || !Array.isArray(featureFlags.data)) return tabOptions + + const disabledPages = featureFlags.data + .filter((flag) => flag.Enabled === false || flag.enabled === false) + .flatMap((flag) => flag.Pages || flag.pages || []) + .filter((page) => typeof page === 'string') + + if (disabledPages.length === 0) return tabOptions + + return tabOptions.filter((option) => !disabledPages.includes(option.path)) + }, [tabOptions, featureFlags.isSuccess, featureFlags.data]) + const handleTabsChange = (event, value) => { // Preserve existing query parameters when changing tabs const currentParams = new URLSearchParams(searchParams.toString()) @@ -16,7 +37,7 @@ export const TabbedLayout = (props) => { router.push(newPath) } - const currentTab = tabOptions.find((option) => option.path === pathname) + const currentTab = visibleTabs.find((option) => option.path === pathname) return ( { }, }} > - {tabOptions.map((option) => ( + {visibleTabs.map((option) => ( ))} diff --git a/src/layouts/index.js b/src/layouts/index.js index b741d5bd0ea4..d00dc338a82d 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -27,6 +27,8 @@ import { CippImageCard } from '../components/CippCards/CippImageCard' import { useDialog } from '../hooks/use-dialog' import { nativeMenuItems } from './config' import { CippBreadcrumbNav } from '../components/CippComponents/CippBreadcrumbNav' +import { SsoMigrationDialog } from '../components/CippComponents/SsoMigrationDialog' +import { ForcedSsoMigrationDialog } from '../components/CippComponents/ForcedSsoMigrationDialog' const OnboardingWizardPage = dynamic( () => import('../components/CippWizard/OnboardingWizardPage.jsx'), @@ -335,6 +337,8 @@ export const Layout = (props) => { + + {!setupCompleted && ( diff --git a/src/pages/cipp/advanced/super-admin/cipp-users.js b/src/pages/cipp/advanced/super-admin/cipp-users.js new file mode 100644 index 000000000000..8fe35569ef16 --- /dev/null +++ b/src/pages/cipp/advanced/super-admin/cipp-users.js @@ -0,0 +1,33 @@ +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import tabOptions from "./tabOptions"; +import CippPageCard from "../../../../components/CippCards/CippPageCard"; +import { CippUserManagement } from "../../../../components/CippSettings/CippUserManagement"; +import { CardContent, Stack, Alert } from "@mui/material"; + +const Page = () => { + return ( + + + + + Manage users who can access CIPP. Add users by their email address (UPN) and assign + them built-in or custom roles. Users not in this list will still be able to log in if + "Allow All Tenant Users" is enabled, but they will only receive default + (authenticated) permissions. Role resolution also considers Entra group mappings + configured on the CIPP Roles page. + + + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/cipp/advanced/super-admin/container.js b/src/pages/cipp/advanced/super-admin/container.js new file mode 100644 index 000000000000..d56595ac546c --- /dev/null +++ b/src/pages/cipp/advanced/super-admin/container.js @@ -0,0 +1,26 @@ +import { Container } from "@mui/material"; +import { Grid } from "@mui/system"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import tabOptions from "./tabOptions"; +import { CippContainerManagement } from "../../../../components/CippSettings/CippContainerManagement"; + +const Page = () => { + return ( + + + + + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/cipp/advanced/super-admin/sso.js b/src/pages/cipp/advanced/super-admin/sso.js new file mode 100644 index 000000000000..fc5b112f3f1c --- /dev/null +++ b/src/pages/cipp/advanced/super-admin/sso.js @@ -0,0 +1,26 @@ +import { Container } from "@mui/material"; +import { Grid } from "@mui/system"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import tabOptions from "./tabOptions"; +import { CippSSOSettings } from "../../../../components/CippSettings/CippSSOSettings"; + +const Page = () => { + return ( + + + + + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/cipp/advanced/super-admin/tabOptions.json b/src/pages/cipp/advanced/super-admin/tabOptions.json index 672df76996c6..fbccb6b73c55 100644 --- a/src/pages/cipp/advanced/super-admin/tabOptions.json +++ b/src/pages/cipp/advanced/super-admin/tabOptions.json @@ -22,5 +22,17 @@ { "label": "SAM App Permissions", "path": "/cipp/advanced/super-admin/sam-app-permissions" + }, + { + "label": "CIPP Users", + "path": "/cipp/advanced/super-admin/cipp-users" + }, + { + "label": "SSO", + "path": "/cipp/advanced/super-admin/sso" + }, + { + "label": "Container Management", + "path": "/cipp/advanced/super-admin/container" } ] From 36071e5403afe1bf5bc8426e49db987ce23a0bb8 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 13 May 2026 14:20:34 +0800 Subject: [PATCH 044/164] Module updates and import changes --- next.config.js | 1 + package.json | 20 +- .../CippCards/CippStandardsDialog.jsx | 10 +- .../CippAppPermissionBuilder.jsx | 6 +- .../CippSettings/CippContainerManagement.jsx | 247 ++++++- .../CippStandards/CippStandardAccordion.jsx | 70 +- .../CippStandards/CippStandardsSideBar.jsx | 22 +- src/components/CippTable/CippDataTable.js | 3 +- .../cipp/advanced/super-admin/container.js | 7 +- src/pages/cipp/settings/features.js | 1 + src/pages/tenant/standards/bpa-report/view.js | 6 +- .../tenant/standards/templates/template.jsx | 20 +- yarn.lock | 626 +----------------- 13 files changed, 343 insertions(+), 696 deletions(-) diff --git a/next.config.js b/next.config.js index 97685f34f91e..f2bc28bcd2bb 100644 --- a/next.config.js +++ b/next.config.js @@ -16,6 +16,7 @@ const config = { 'mui-tiptap', 'recharts', '@react-pdf/renderer', + 'lodash', ], webpackMemoryOptimizations: true, preloadEntriesOnStart: false, diff --git a/package.json b/package.json index 9a98ddfc4b3a..a4402ea681b5 100644 --- a/package.json +++ b/package.json @@ -48,28 +48,26 @@ "@tanstack/react-table": "^8.19.2", "@tiptap/core": "^3.4.1", "@tiptap/extension-heading": "^3.4.1", - "@tiptap/extension-image": "^3.20.5", "@tiptap/extension-table": "^3.19.0", "@tiptap/pm": "^3.22.3", "@tiptap/react": "^3.20.5", "@tiptap/starter-kit": "^3.20.5", - "@uiw/react-json-view": "^2.0.0-alpha.42", "@vvo/tzdb": "^6.198.0", "apexcharts": "5.10.4", "axios": "1.15.0", "date-fns": "4.1.0", "diff": "^8.0.3", + "dompurify": "^3.4.2", "eml-parse-js": "^1.2.0-beta.0", "export-to-csv": "^1.3.0", "formik": "2.4.9", "gray-matter": "4.0.3", - "i18next": "25.8.18", "javascript-time-ago": "^2.6.2", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", "leaflet": "^1.9.4", - "leaflet-defaulticon-compatibility": "^0.1.2", "leaflet.markercluster": "^1.5.3", + "lodash": "^4.18.1", "lodash.isequal": "4.5.0", "material-react-table": "^3.0.1", "monaco-editor": "^0.55.1", @@ -82,15 +80,12 @@ "react": "19.2.5", "react-apexcharts": "2.1.0", "react-beautiful-dnd": "13.1.1", - "react-copy-to-clipboard": "^5.1.0", "react-dom": "19.2.5", "react-dropzone": "15.0.0", "react-error-boundary": "^6.1.1", - "react-grid-layout": "^2.2.3", "react-hook-form": "^7.72.0", "react-hot-toast": "2.6.0", "react-html-parser": "^2.0.2", - "react-i18next": "16.6.5", "react-leaflet": "5.0.0", "react-leaflet-markercluster": "^5.0.0-rc.0", "react-markdown": "10.1.0", @@ -101,28 +96,23 @@ "react-syntax-highlighter": "^16.1.0", "react-time-ago": "^7.3.3", "react-virtuoso": "^4.18.5", - "react-window": "^2.2.7", "recharts": "^3.8.1", "redux": "5.0.1", - "redux-devtools-extension": "2.13.9", "redux-persist": "^6.0.0", - "redux-thunk": "3.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", + "remark-parse": "^11.0.0", "simplebar": "6.3.3", "simplebar-react": "3.3.2", "stylis-plugin-rtl": "2.1.1", - "typescript": "5.9.3", + "unified": "^11.0.5", "yup": "1.7.1" }, "devDependencies": { "@svgr/webpack": "8.1.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", "eslint-config-next": "^16.2.3", "eslint-config-prettier": "^10.1.8", - "prettier": "^3.8.1", - "prettier-eslint": "^16.4.2" + "prettier": "^3.8.1" } } diff --git a/src/components/CippCards/CippStandardsDialog.jsx b/src/components/CippCards/CippStandardsDialog.jsx index 86de00f07d92..0e006ef43615 100644 --- a/src/components/CippCards/CippStandardsDialog.jsx +++ b/src/components/CippCards/CippStandardsDialog.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import _ from 'lodash' +import { get } from 'lodash' import { Dialog, DialogTitle, @@ -311,7 +311,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan {info.addedComponent.map((component, componentIndex) => { - const value = _.get(templateItem, component.name) + const value = get(templateItem, component.name) let displayValue = 'N/A' if (value) { @@ -427,7 +427,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan let extractedValue = null // Try direct access first - componentValue = _.get(config, component.name) + componentValue = get(config, component.name) // If direct access fails and component name contains dots (nested structure) if ( @@ -441,7 +441,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan if (pathParts[0] === 'standards' && config.standards) { // Remove 'standards.' prefix and try to find the value in config.standards const nestedPath = pathParts.slice(1).join('.') - extractedValue = _.get(config.standards, nestedPath) + extractedValue = get(config.standards, nestedPath) // If still not found, try alternative nested structures // Some standards have double nesting like: config.standards.StandardName.fieldName @@ -452,7 +452,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan ) { const standardName = pathParts[1] const fieldPath = pathParts.slice(2).join('.') - extractedValue = _.get( + extractedValue = get( config.standards, `${standardName}.${fieldPath}` ) diff --git a/src/components/CippComponents/CippAppPermissionBuilder.jsx b/src/components/CippComponents/CippAppPermissionBuilder.jsx index da386b770f91..07d21613fb84 100644 --- a/src/components/CippComponents/CippAppPermissionBuilder.jsx +++ b/src/components/CippComponents/CippAppPermissionBuilder.jsx @@ -37,7 +37,7 @@ import { import { useWatch } from "react-hook-form"; import { CippCardTabPanel } from "./CippCardTabPanel"; import { CippApiResults } from "./CippApiResults"; -import _ from "lodash"; +import { isEqual } from "lodash"; import { CippCodeBlock } from "./CippCodeBlock"; import { CippOffCanvas } from "./CippOffCanvas"; import { FileDropzone } from "../file-dropzone"; @@ -388,7 +388,7 @@ const CippAppPermissionBuilder = ({ }); setExpanded("00000003-0000-0000-c000-000000000000"); // Automatically expand Microsoft Graph } - } else if (!_.isEqual(currentPermissions, initialPermissions)) { + } else if (!isEqual(currentPermissions, initialPermissions)) { setSelectedApp([]); // Avoid redundant updates setNewPermissions(currentPermissions); setInitialPermissions(currentPermissions); @@ -398,7 +398,7 @@ const CippAppPermissionBuilder = ({ initialAppIds.includes(sp.appId), )?.sort((a, b) => a.displayName.localeCompare(b.displayName)); - if (!_.isEqual(selectedApp, newApps)) { + if (!isEqual(selectedApp, newApps)) { setSelectedApp(newApps); // Prevent unnecessary updates } diff --git a/src/components/CippSettings/CippContainerManagement.jsx b/src/components/CippSettings/CippContainerManagement.jsx index 526051233c70..37daf6aa3e45 100644 --- a/src/components/CippSettings/CippContainerManagement.jsx +++ b/src/components/CippSettings/CippContainerManagement.jsx @@ -24,24 +24,55 @@ const channelLabels = { unknown: { label: "Unknown", color: "default" }, }; +const intervalOptions = [ + { label: "Disabled", value: "0" }, + { label: "Every hour", value: "1h" }, + { label: "Every 4 hours", value: "4h" }, + { label: "Every 12 hours", value: "12h" }, + { label: "Every day", value: "1d" }, +]; + +const hourOptions = Array.from({ length: 24 }, (_, i) => ({ + label: `${i.toString().padStart(2, "0")}:00`, + value: String(i), +})); + export const CippContainerManagement = () => { - const formControl = useForm({ + const channelForm = useForm({ mode: "onChange", defaultValues: { Channel: null }, }); + const updateSettingsForm = useForm({ + mode: "onChange", + defaultValues: { CheckInterval: null, AutoUpdate: false, CheckTime: null }, + }); + const containerStatus = ApiGetCall({ url: "/api/ExecContainerManagement", data: { Action: "Status" }, queryKey: "containerStatus", }); - const containerAction = ApiPostCall({ + const channelAction = ApiPostCall({ + relatedQueryKeys: ["containerStatus"], + }); + + const restartAction = ApiPostCall({ + relatedQueryKeys: ["containerStatus"], + }); + + const updateCheckAction = ApiPostCall({ + relatedQueryKeys: ["containerStatus"], + }); + + const updateSettingsAction = ApiPostCall({ relatedQueryKeys: ["containerStatus"], }); const data = containerStatus.data?.Results; const channelInfo = channelLabels[data?.CurrentChannel] ?? channelLabels.unknown; + const updateSettings = data?.UpdateSettings; const channelOptions = (data?.ValidChannels ?? ["latest", "dev", "nightly"]).map((c) => ({ label: channelLabels[c]?.label ?? c, @@ -52,29 +83,75 @@ export const CippContainerManagement = () => { if (containerStatus.isSuccess && data?.CurrentChannel) { const current = channelOptions.find((o) => o.value === data.CurrentChannel); if (current) { - formControl.reset({ Channel: current }); + channelForm.reset({ Channel: current }); } } }, [containerStatus.isSuccess, data?.CurrentChannel]); + useEffect(() => { + if (containerStatus.isSuccess && updateSettings) { + const interval = intervalOptions.find((o) => o.value === (updateSettings.CheckInterval ?? "0")); + const hour = updateSettings.CheckTime != null + ? hourOptions.find((o) => o.value === String(updateSettings.CheckTime)) + : null; + updateSettingsForm.reset({ + CheckInterval: interval ?? intervalOptions[0], + AutoUpdate: updateSettings.AutoUpdate ?? false, + CheckTime: hour ?? null, + }); + } + }, [containerStatus.isSuccess, updateSettings?.CheckInterval, updateSettings?.AutoUpdate, updateSettings?.CheckTime]); + const handleUpdateChannel = () => { - const selected = formControl.getValues("Channel"); + const selected = channelForm.getValues("Channel"); const channel = selected?.value ?? selected; - containerAction.mutate({ + channelAction.mutate({ url: "/api/ExecContainerManagement", data: { Action: "UpdateChannel", Channel: channel }, }); }; const handleRestart = () => { - containerAction.mutate({ + restartAction.mutate({ url: "/api/ExecContainerManagement", data: { Action: "Restart" }, }); }; + const handleCheckUpdate = () => { + updateCheckAction.mutate({ + url: "/api/ExecContainerManagement", + data: { Action: "CheckUpdate" }, + }); + }; + + const handleSaveUpdateSettings = () => { + const interval = updateSettingsForm.getValues("CheckInterval"); + const autoUpdate = updateSettingsForm.getValues("AutoUpdate"); + const checkTime = updateSettingsForm.getValues("CheckTime"); + updateSettingsAction.mutate({ + url: "/api/ExecContainerManagement", + data: { + Action: "SaveUpdateSettings", + CheckInterval: interval?.value ?? interval ?? "0", + AutoUpdate: autoUpdate ?? false, + CheckTime: checkTime?.value ?? checkTime ?? null, + }, + }); + }; + + const truncateDigest = (digest) => { + if (!digest) return "—"; + // Show algo prefix + first 12 hex chars + if (digest.startsWith("sha256:")) { + return `sha256:${digest.slice(7, 19)}…`; + } + return digest.length > 20 ? `${digest.slice(0, 20)}…` : digest; + }; + return ( - + + {containerStatus.isLoading ? ( @@ -91,6 +168,11 @@ export const CippContainerManagement = () => { apply. )} + {updateSettings?.UpdateAvailable && ( + + A container update is available. Restart the container to pull the latest image. + + )} @@ -134,6 +216,25 @@ export const CippContainerManagement = () => { + {updateSettings?.RunningDigest && ( + <> + + + Container Digest + + + + + {truncateDigest(updateSettings.RunningDigest)} + + + + )} + {data?.CurrentImage && data.CurrentImage !== "unknown" && ( <> @@ -142,7 +243,10 @@ export const CippContainerManagement = () => { - + {data.CurrentImage} @@ -166,7 +270,116 @@ export const CippContainerManagement = () => { )} + + + + + + + Configure automatic update checking. CIPP will query the container registry for a new + image digest and optionally restart the container to apply the update. + NOTE: If the container restarts for any reason the latest image version for your update channel will be pulled regardless + + + + + + + + + + + + + {updateSettings?.LastCheck && ( + + Last checked: {new Date(updateSettings.LastCheck * 1000).toLocaleString()} + {updateSettings.UpdateAvailable ? ( + + ) : ( + + )} + + )} + + {updateSettings?.RunningDigest && updateSettings?.RemoteDigest && ( + + + + Running Digest + + + + + {truncateDigest(updateSettings.RunningDigest)} + + + + + Remote Digest + + + + + {truncateDigest(updateSettings.RemoteDigest)} + + + + )} + + + + + + + + + + + + @@ -180,43 +393,49 @@ export const CippContainerManagement = () => { name="Channel" label="Release Channel" options={channelOptions} - formControl={formControl} + formControl={channelForm} creatable={false} multiple={false} /> - + + + + Restart the application container. This will cause a brief downtime while the container restarts. If you changed the release channel, this will pull the new image. + + - + + ); }; diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index b42c73d6c5ba..5aed7f6950a8 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -32,7 +32,7 @@ import { import { Grid } from "@mui/system"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { useWatch, useFormState } from "react-hook-form"; -import _ from "lodash"; +import { get, isEqual, cloneDeep } from "lodash"; import Microsoft from "../../icons/iconly/bulk/microsoft"; import Azure from "../../icons/iconly/bulk/azure"; import Exchange from "../../icons/iconly/bulk/exchange"; @@ -168,7 +168,7 @@ const CippStandardAccordion = ({ // ALWAYS require an action for any standard to be considered configured // The action field should be an array with at least one element - const actionValue = _.get(values, "action"); + const actionValue = get(values, "action"); if (!actionValue || (Array.isArray(actionValue) && actionValue.length === 0)) return false; // Additional checks for required components @@ -188,7 +188,7 @@ const CippStandardAccordion = ({ // Handle conditional fields if (component.condition) { const conditionField = component.condition.field; - const conditionValue = _.get(values, conditionField); + const conditionValue = get(values, conditionField); const compareType = component.condition.compareType || "is"; const compareValue = component.condition.compareValue; const propertyName = component.condition.propertyName || "value"; @@ -197,10 +197,10 @@ const CippStandardAccordion = ({ if (propertyName === "value") { switch (compareType) { case "is": - conditionMet = _.isEqual(conditionValue, compareValue); + conditionMet = isEqual(conditionValue, compareValue); break; case "isNot": - conditionMet = !_.isEqual(conditionValue, compareValue); + conditionMet = !isEqual(conditionValue, compareValue); break; default: conditionMet = false; @@ -224,7 +224,7 @@ const CippStandardAccordion = ({ if (!isRequired) return true; // Get field value using lodash's get to properly handle nested properties - const fieldValue = _.get(values, component.name); + const fieldValue = get(values, component.name); // Check if field has a value based on its type and multiple property if (component.type === "autoComplete" || component.type === "select") { @@ -263,10 +263,10 @@ const CippStandardAccordion = ({ // For each standard, get its current values and determine if it's configured Object.keys(selectedStandards).forEach((standardName) => { - const currentValues = _.get(watchedValues, standardName); + const currentValues = get(watchedValues, standardName); if (!currentValues) return; - initial[standardName] = _.cloneDeep(currentValues); + initial[standardName] = cloneDeep(currentValues); const baseStandardName = standardName.split("[")[0]; const standard = providedStandards.find((s) => s.name === baseStandardName); @@ -305,9 +305,9 @@ const CippStandardAccordion = ({ const updated = { ...prev }; removedKeys.forEach((k) => delete updated[k]); addedKeys.forEach((k) => { - const currentValues = _.get(watchedValues, k); + const currentValues = get(watchedValues, k); if (currentValues) { - updated[k] = _.cloneDeep(currentValues); + updated[k] = cloneDeep(currentValues); } }); return updated; @@ -319,7 +319,7 @@ const CippStandardAccordion = ({ addedKeys.forEach((k) => { const baseStandardName = k.split("[")[0]; const standard = providedStandards.find((s) => s.name === baseStandardName); - const currentValues = _.get(watchedValues, k); + const currentValues = get(watchedValues, k); if (standard && currentValues) { updated[k] = isStandardConfigured(k, standard, currentValues); } @@ -333,7 +333,7 @@ const CippStandardAccordion = ({ // Save changes for a standard const handleSave = (standardName, standard, current) => { // Clone the current values to avoid reference issues - const newValues = _.cloneDeep(current); + const newValues = cloneDeep(current); // Update saved values setSavedValues((prev) => ({ @@ -369,11 +369,11 @@ const CippStandardAccordion = ({ // Cancel changes for a standard const handleCancel = (standardName) => { // Get the last saved values - const savedValue = _.get(savedValues, standardName); + const savedValue = get(savedValues, standardName); if (!savedValue) return; // Set the entire standard's value at once to ensure proper handling of nested objects and arrays - formControl.setValue(standardName, _.cloneDeep(savedValue)); + formControl.setValue(standardName, cloneDeep(savedValue)); // Find the original standard definition to get the base standard const baseStandardName = standardName.split("[")[0]; @@ -454,7 +454,7 @@ const CippStandardAccordion = ({ Array.isArray(standard.appliesToTest) && standard.appliesToTest.some((testId) => testId.toLowerCase().includes(searchLower))); - const isConfigured = _.get(configuredState, standardName); + const isConfigured = get(configuredState, standardName); const matchesFilter = filter === "all" || (filter === "configured" && isConfigured) || @@ -616,10 +616,10 @@ const CippStandardAccordion = ({ const isExpanded = expanded === standardName; const hasAddedComponents = standard.addedComponent && standard.addedComponent.length > 0; - const isConfigured = _.get(configuredState, standardName); + const isConfigured = get(configuredState, standardName); const disabledFeatures = standard.disabledFeatures || {}; - let selectedActions = _.get(watchedValues, `${standardName}.action`); + let selectedActions = get(watchedValues, `${standardName}.action`); if (selectedActions && !Array.isArray(selectedActions)) { selectedActions = [selectedActions]; } @@ -628,13 +628,13 @@ const CippStandardAccordion = ({ let templateDisplayName = ""; if (standardName.startsWith("standards.IntuneTemplate")) { // Check for TemplateList selection - const templateList = _.get(watchedValues, `${standardName}.TemplateList`); + const templateList = get(watchedValues, `${standardName}.TemplateList`); if (templateList && templateList.label) { templateDisplayName = templateList.label; } // Check for TemplateList-Tags selection (takes priority) - const templateListTags = _.get(watchedValues, `${standardName}.TemplateList-Tags`); + const templateListTags = get(watchedValues, `${standardName}.TemplateList-Tags`); if (templateListTags && templateListTags.label) { templateDisplayName = templateListTags.label; } @@ -642,21 +642,21 @@ const CippStandardAccordion = ({ // For multiple standards, check the first added component const selectedTemplateName = standard.multiple - ? _.get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) + ? get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) : ""; // Build accordion title with template name if available const accordionTitle = templateDisplayName ? `${standard.label} - ${templateDisplayName}` - : selectedTemplateName && _.get(selectedTemplateName, "label") - ? `${standard.label} - ${_.get(selectedTemplateName, "label")}` + : selectedTemplateName && get(selectedTemplateName, "label") + ? `${standard.label} - ${get(selectedTemplateName, "label")}` : standard.label; // Get current values and check if they differ from saved values - const current = _.get(watchedValues, standardName); - const saved = _.get(savedValues, standardName) || {}; + const current = get(watchedValues, standardName); + const saved = get(savedValues, standardName) || {}; - const hasUnsaved = !_.isEqual(current, saved); + const hasUnsaved = !isEqual(current, saved); // Check if all required fields are filled const requiredFieldsFilled = current @@ -671,7 +671,7 @@ const CippStandardAccordion = ({ // Handle conditional fields if (component.condition) { const conditionField = component.condition.field; - const conditionValue = _.get(current, conditionField); + const conditionValue = get(current, conditionField); const compareType = component.condition.compareType || "is"; const compareValue = component.condition.compareValue; const propertyName = component.condition.propertyName || "value"; @@ -680,10 +680,10 @@ const CippStandardAccordion = ({ if (propertyName === "value") { switch (compareType) { case "is": - conditionMet = _.isEqual(conditionValue, compareValue); + conditionMet = isEqual(conditionValue, compareValue); break; case "isNot": - conditionMet = !_.isEqual(conditionValue, compareValue); + conditionMet = !isEqual(conditionValue, compareValue); break; default: conditionMet = false; @@ -705,7 +705,7 @@ const CippStandardAccordion = ({ } // Get field value for validation using lodash's get to properly handle nested properties - const fieldValue = _.get(current, component.name); + const fieldValue = get(current, component.name); // Check if required field has a value based on its type and multiple property if (component.type === "autoComplete" || component.type === "select") { @@ -734,12 +734,12 @@ const CippStandardAccordion = ({ ); // Action is always required and must be an array with at least one element - const actionValue = _.get(current, "action"); + const actionValue = get(current, "action"); const hasAction = actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); // Check if this standard has any validation errors - const standardErrors = _.get(formErrors, standardName); + const standardErrors = get(formErrors, standardName); const hasValidationErrors = standardErrors && Object.keys(standardErrors).length > 0; // Allow saving if: @@ -957,7 +957,7 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} - currentValue={_.get( + currentValue={get( watchedValues, `${standardName}.${component.name}`, )} @@ -969,7 +969,7 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} - currentValue={_.get( + currentValue={get( watchedValues, `${standardName}.${component.name}`, )} @@ -1023,7 +1023,7 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} - currentValue={_.get( + currentValue={get( watchedValues, `${standardName}.${component.name}`, )} @@ -1035,7 +1035,7 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} - currentValue={_.get( + currentValue={get( watchedValues, `${standardName}.${component.name}`, )} diff --git a/src/components/CippStandards/CippStandardsSideBar.jsx b/src/components/CippStandards/CippStandardsSideBar.jsx index f633f9b14ed9..2cab69c42f7e 100644 --- a/src/components/CippStandards/CippStandardsSideBar.jsx +++ b/src/components/CippStandards/CippStandardsSideBar.jsx @@ -29,7 +29,7 @@ import CheckIcon from "@heroicons/react/24/outline/CheckIcon"; import CloseIcon from "@mui/icons-material/Close"; import { useWatch } from "react-hook-form"; import { useEffect, useState } from "react"; -import _ from "lodash"; +import { get } from "lodash"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { CippFormTenantSelector } from "../CippComponents/CippFormTenantSelector"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; @@ -241,14 +241,14 @@ const CippStandardsSideBar = ({ useEffect(() => { const stepsStatus = { - step1: !!_.get(watchForm, "templateName"), - step2: _.get(watchForm, "tenantFilter", []).length > 0, + step1: !!get(watchForm, "templateName"), + step2: get(watchForm, "tenantFilter", []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - _.get(watchForm, "standards") && + get(watchForm, "standards") && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, `${standardName}`, {}); + const standardValues = get(watchForm, `${standardName}`, {}); const standard = selectedStandards[standardName]; // Check if this standard requires an action const hasRequiredComponents = @@ -258,7 +258,7 @@ const CippStandardsSideBar = ({ ); const actionRequired = standard?.disabledFeatures !== undefined || hasRequiredComponents; // Always require an action value which should be an array with at least one element - const actionValue = _.get(standardValues, "action"); + const actionValue = get(standardValues, "action"); return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); }), }; @@ -269,17 +269,17 @@ const CippStandardsSideBar = ({ // Create a local reference to the stepsStatus from the latest effect run const stepsStatus = { - step1: !!_.get(watchForm, "templateName"), - step2: _.get(watchForm, "tenantFilter", []).length > 0, + step1: !!get(watchForm, "templateName"), + step2: get(watchForm, "tenantFilter", []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - _.get(watchForm, "standards") && + get(watchForm, "standards") && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, `${standardName}`, {}); + const standardValues = get(watchForm, `${standardName}`, {}); const standard = selectedStandards[standardName]; // Always require an action for all standards (must be an array with at least one element) - const actionValue = _.get(standardValues, "action"); + const actionValue = get(standardValues, "action"); return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); }), }; diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 253327713912..22c9bfe66ff2 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -341,6 +341,7 @@ export const CippDataTable = (props) => { }, exportEnabled = true, simpleColumns = [], + dataFilter, actions, title = 'Report', simple = false, @@ -476,7 +477,7 @@ export const CippDataTable = (props) => { const nestedData = getNestedValue(page, api.dataKey) return nestedData !== undefined ? nestedData : [] }) - setUsedData(combinedResults) + setUsedData(dataFilter ? combinedResults.filter(dataFilter) : combinedResults) } }, [ getRequestData.isSuccess, diff --git a/src/pages/cipp/advanced/super-admin/container.js b/src/pages/cipp/advanced/super-admin/container.js index d56595ac546c..9fb8a701174b 100644 --- a/src/pages/cipp/advanced/super-admin/container.js +++ b/src/pages/cipp/advanced/super-admin/container.js @@ -1,5 +1,4 @@ import { Container } from "@mui/material"; -import { Grid } from "@mui/system"; import { TabbedLayout } from "../../../../layouts/TabbedLayout"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import tabOptions from "./tabOptions"; @@ -8,11 +7,7 @@ import { CippContainerManagement } from "../../../../components/CippSettings/Cip const Page = () => { return ( - - - - - + ); }; diff --git a/src/pages/cipp/settings/features.js b/src/pages/cipp/settings/features.js index 15b6fd3a111e..d630e93eb2ba 100644 --- a/src/pages/cipp/settings/features.js +++ b/src/pages/cipp/settings/features.js @@ -59,6 +59,7 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} tenantInTitle={false} + dataFilter={(row) => !row.Hidden} /> ); }; diff --git a/src/pages/tenant/standards/bpa-report/view.js b/src/pages/tenant/standards/bpa-report/view.js index f85fb633a3a3..6abd0203b330 100644 --- a/src/pages/tenant/standards/bpa-report/view.js +++ b/src/pages/tenant/standards/bpa-report/view.js @@ -10,7 +10,7 @@ import { useEffect, useState } from "react"; import CippButtonCard from "../../../../components/CippCards/CippButtonCard"; import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; import { CippImageCard } from "../../../../components/CippCards/CippImageCard"; -import _ from "lodash"; +import { get } from "lodash"; const Page = () => { const router = useRouter(); const { id } = router.query; @@ -52,8 +52,8 @@ const Page = () => { const tenantData = bpaData?.data?.Data?.find((data) => data.GUID === tenantId); const cards = frontendFields.map((field) => { //instead of this, use lodash to get the data for blockData - const blockData = _.get(tenantData, field.value) - ? _.get(tenantData, field.value) + const blockData = get(tenantData, field.value) + ? get(tenantData, field.value) : undefined; return { name: field.name, diff --git a/src/pages/tenant/standards/templates/template.jsx b/src/pages/tenant/standards/templates/template.jsx index 630fdee6f2ce..7e442863b3f1 100644 --- a/src/pages/tenant/standards/templates/template.jsx +++ b/src/pages/tenant/standards/templates/template.jsx @@ -15,7 +15,7 @@ import CippStandardsSideBar from '../../../../components/CippStandards/CippStand import { ArrowLeftIcon } from '@mui/x-date-pickers' import { useDialog } from '../../../../hooks/use-dialog' import { ApiGetCall } from '../../../../api/ApiCall' -import _ from 'lodash' +import { get } from 'lodash' import { createDriftManagementActions } from '../../manage/driftManagementActions' import { ActionsMenu } from '../../../../components/actions-menu' import { useSettings } from '../../../../hooks/use-settings' @@ -61,16 +61,16 @@ const Page = () => { // Check if the template configuration is valid and update currentStep useEffect(() => { const stepsStatus = { - step1: !!_.get(watchForm, 'templateName'), - step2: _.get(watchForm, 'tenantFilter', []).length > 0, + step1: !!get(watchForm, 'templateName'), + step2: get(watchForm, 'tenantFilter', []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - _.get(watchForm, 'standards') && + get(watchForm, 'standards') && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, standardName, {}) + const standardValues = get(watchForm, standardName, {}) // Always require an action value which should be an array with at least one element - const actionValue = _.get(standardValues, 'action') + const actionValue = get(standardValues, 'action') return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0) }), } @@ -299,12 +299,12 @@ const Page = () => { // Determine if save button should be disabled based on configuration const isSaveDisabled = isDriftMode - ? !_.get(watchForm, 'tenantFilter') || - !_.get(watchForm, 'tenantFilter').length || + ? !get(watchForm, 'tenantFilter') || + !get(watchForm, 'tenantFilter').length || currentStep < 4 || hasDriftConflict // For drift mode, require all steps and no drift conflicts - : !_.get(watchForm, 'tenantFilter') || - !_.get(watchForm, 'tenantFilter').length || + : !get(watchForm, 'tenantFilter') || + !get(watchForm, 'tenantFilter').length || currentStep < 4 // Create drift management actions (excluding refresh) diff --git a/yarn.lock b/yarn.lock index 847ef311f58e..acfda103a76e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1016,14 +1016,14 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2", "@eslint-community/regexpp@^4.6.1": +"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== @@ -1051,21 +1051,6 @@ dependencies: "@types/json-schema" "^7.0.15" -"@eslint/eslintrc@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" - integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^9.6.0" - globals "^13.19.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - "@eslint/eslintrc@^3.3.5": version "3.3.5" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" @@ -1081,11 +1066,6 @@ minimatch "^3.1.5" strip-json-comments "^3.1.1" -"@eslint/js@8.57.1": - version "8.57.1" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" - integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== - "@eslint/js@9.39.4": version "9.39.4" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.4.tgz#a3f83bfc6fd9bf33a853dfacd0b49b398eb596c1" @@ -1142,25 +1122,11 @@ "@humanfs/core" "^0.19.1" "@humanwhocodes/retry" "^0.4.0" -"@humanwhocodes/config-array@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" - integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== - dependencies: - "@humanwhocodes/object-schema" "^2.0.3" - debug "^4.3.1" - minimatch "^3.0.5" - "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/object-schema@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" - integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== - "@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": version "0.4.3" resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" @@ -1313,13 +1279,6 @@ resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8" integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw== -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== - dependencies: - "@sinclair/typebox" "^0.27.8" - "@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" @@ -1722,7 +1681,7 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": +"@nodelib/fs.walk@^1.2.3": version "1.2.8" resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== @@ -1950,11 +1909,6 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@sinclair/typebox@^0.27.8": - version "0.27.10" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.10.tgz#beefe675f1853f73676aecc915b2bd2ac98c4fc6" - integrity sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA== - "@sinonjs/text-encoding@^0.7.2": version "0.7.3" resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz#282046f03e886e352b2d5f5da5eb755e01457f3f" @@ -2263,11 +2217,6 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.5.tgz#c21b2c7405f4aad7b507e36cc3394aba51ea2253" integrity sha512-4UtpUHg8cRzxWjJUGtni5VnXYbhsO7ygf1H1pr4Rv63XMBg9lfYDeSwByIuVy9biEFP7eGEFnezzb5Zlh1btmQ== -"@tiptap/extension-image@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-3.20.5.tgz#90a80ce694dcda452a296d38f457bfbe72bf940d" - integrity sha512-qxKupWKhX75Xc9GJ9Uel+KIFL9x6tb8W3RvQM1UolyJX/H7wyBO7sXp9XmKRkHZsDXRgLVbnkYBe+X83o16AIA== - "@tiptap/extension-italic@^3.20.5": version "3.20.5" resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.20.5.tgz#c53436f05968b16eda6b8e0efbaebaf3f4587e3b" @@ -2597,11 +2546,6 @@ resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.3.tgz#85f1d1d17569b28b8db45e16e996407a56b0ab04" integrity sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw== -"@types/react-dom@^19.2.3": - version "19.2.3" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" - integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== - "@types/react-redux@^7.1.20": version "7.1.34" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.34.tgz#83613e1957c481521e6776beeac4fd506d11bd0e" @@ -2617,7 +2561,7 @@ resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@*", "@types/react@^19.2.14": +"@types/react@*": version "19.2.14" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad" integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== @@ -2669,17 +2613,6 @@ "@typescript-eslint/visitor-keys" "8.57.1" debug "^4.4.3" -"@typescript-eslint/parser@^6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b" - integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== - dependencies: - "@typescript-eslint/scope-manager" "6.21.0" - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/typescript-estree" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" - debug "^4.3.4" - "@typescript-eslint/project-service@8.57.1": version "8.57.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.57.1.tgz#16af9fe16eedbd7085e4fdc29baa73715c0c55c5" @@ -2689,14 +2622,6 @@ "@typescript-eslint/types" "^8.57.1" debug "^4.4.3" -"@typescript-eslint/scope-manager@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1" - integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg== - dependencies: - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" - "@typescript-eslint/scope-manager@8.57.1": version "8.57.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz#4524d7e7b420cb501807499684d435ae129aaf35" @@ -2721,30 +2646,11 @@ debug "^4.4.3" ts-api-utils "^2.4.0" -"@typescript-eslint/types@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" - integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== - "@typescript-eslint/types@8.57.1", "@typescript-eslint/types@^8.57.1": version "8.57.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.57.1.tgz#54b27a8a25a7b45b4f978c3f8e00c4c78f11142c" integrity sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ== -"@typescript-eslint/typescript-estree@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46" - integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ== - dependencies: - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - minimatch "9.0.3" - semver "^7.5.4" - ts-api-utils "^1.0.1" - "@typescript-eslint/typescript-estree@8.57.1": version "8.57.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz#a9fd28d4a0ec896aa9a9a7e0cead62ea24f99e76" @@ -2770,14 +2676,6 @@ "@typescript-eslint/types" "8.57.1" "@typescript-eslint/typescript-estree" "8.57.1" -"@typescript-eslint/visitor-keys@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47" - integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A== - dependencies: - "@typescript-eslint/types" "6.21.0" - eslint-visitor-keys "^3.4.1" - "@typescript-eslint/visitor-keys@8.57.1": version "8.57.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz#3af4f88118924d3be983d4b8ae84803f11fe4563" @@ -2786,12 +2684,7 @@ "@typescript-eslint/types" "8.57.1" eslint-visitor-keys "^5.0.0" -"@uiw/react-json-view@^2.0.0-alpha.42": - version "2.0.0-alpha.42" - resolved "https://registry.yarnpkg.com/@uiw/react-json-view/-/react-json-view-2.0.0-alpha.42.tgz#0830cfa6767debb621c10ff71201c2302605c096" - integrity sha512-PY7IF+zL3gYaW/FG3th0w6JG2SpkYqh/UZOgKm2XuY/UpCZ5inWlopR+pfRadRz/k/uTaOhsQa9jZnlp8QBJDA== - -"@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.2.0": +"@ungap/structured-clone@^1.0.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== @@ -2908,12 +2801,12 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.15.0, acorn@^8.9.0: +acorn@^8.15.0: version "8.16.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== -ajv@^6.12.4, ajv@^6.14.0: +ajv@^6.14.0: version "6.14.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== @@ -2923,21 +2816,6 @@ ajv@^6.12.4, ajv@^6.14.0: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA== - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" - integrity sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA== - ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" @@ -2945,11 +2823,6 @@ ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" - integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== - apexcharts@5.10.4: version "5.10.4" resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-5.10.4.tgz#79c9a05ab40b069f33873a1859de6cb0882ccf0e" @@ -2994,11 +2867,6 @@ array-includes@^3.1.6, array-includes@^3.1.8, array-includes@^3.1.9: is-string "^1.1.1" math-intrinsics "^1.1.0" -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - array.prototype.findlast@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" @@ -3202,13 +3070,6 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brace-expansion@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== - dependencies: - balanced-match "^1.0.0" - brace-expansion@^5.0.2: version "5.0.4" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.4.tgz#614daaecd0a688f660bbbc909a8748c3d80d4336" @@ -3313,17 +3174,6 @@ ccount@^2.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== -chalk@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" - integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A== - dependencies: - ansi-styles "^2.2.1" - escape-string-regexp "^1.0.2" - has-ansi "^2.0.0" - strip-ansi "^3.0.0" - supports-color "^2.0.0" - chalk@^4.0.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -3404,11 +3254,6 @@ commander@^7.2.0: resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== -common-tags@^1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" - integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -3424,13 +3269,6 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -copy-to-clipboard@^3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" - integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA== - dependencies: - toggle-selection "^1.0.6" - core-js-compat@^3.48.0: version "3.49.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.49.0.tgz#06145447d92f4aaf258a0c44f24b47afaeaffef6" @@ -3474,7 +3312,7 @@ crelt@^1.0.0: resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== -cross-spawn@^7.0.2, cross-spawn@^7.0.6: +cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -3720,7 +3558,7 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0, debug@^4.4.3: +debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.0, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -3816,18 +3654,6 @@ diff@^8.0.3: resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.4.tgz#4f5baf3188b9b2431117b962eb20ba330fadf696" integrity sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw== -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - -dlv@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" - integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== - doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -3835,13 +3661,6 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - dom-helpers@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" @@ -3905,6 +3724,13 @@ dompurify@^3.3.1: optionalDependencies: "@types/trusted-types" "^2.0.7" +dompurify@^3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.2.tgz#f0ff81be682c485505097ba8195a058d8f575218" + integrity sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + domutils@^1.5.1: version "1.7.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" @@ -4137,11 +3963,6 @@ escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-string-regexp@^1.0.2: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -4282,14 +4103,6 @@ eslint-plugin-react@^7.37.0: string.prototype.matchall "^4.0.12" string.prototype.repeat "^1.0.0" -eslint-scope@^7.1.1, eslint-scope@^7.2.2: - version "7.2.2" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" - integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== - dependencies: - esrecurse "^4.3.0" - estraverse "^5.2.0" - eslint-scope@^8.4.0: version "8.4.0" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" @@ -4298,7 +4111,7 @@ eslint-scope@^8.4.0: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== @@ -4313,50 +4126,6 @@ eslint-visitor-keys@^5.0.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== -eslint@^8.57.1: - version "8.57.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" - integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== - dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^2.1.4" - "@eslint/js" "8.57.1" - "@humanwhocodes/config-array" "^0.13.0" - "@humanwhocodes/module-importer" "^1.0.1" - "@nodelib/fs.walk" "^1.2.8" - "@ungap/structured-clone" "^1.2.0" - ajv "^6.12.4" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.3.2" - doctrine "^3.0.0" - escape-string-regexp "^4.0.0" - eslint-scope "^7.2.2" - eslint-visitor-keys "^3.4.3" - espree "^9.6.1" - esquery "^1.4.2" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - find-up "^5.0.0" - glob-parent "^6.0.2" - globals "^13.19.0" - graphemer "^1.4.0" - ignore "^5.2.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - is-path-inside "^3.0.3" - js-yaml "^4.1.0" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" - natural-compare "^1.4.0" - optionator "^0.9.3" - strip-ansi "^6.0.1" - text-table "^0.2.0" - eslint@^9.39.4: version "9.39.4" resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.4.tgz#855da1b2e2ad66dc5991195f35e262bcec8117b5" @@ -4406,21 +4175,12 @@ espree@^10.0.1, espree@^10.4.0: acorn-jsx "^5.3.2" eslint-visitor-keys "^4.2.1" -espree@^9.3.1, espree@^9.6.0, espree@^9.6.1: - version "9.6.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" - integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== - dependencies: - acorn "^8.9.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.1" - esprima@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.4.0, esquery@^1.4.2, esquery@^1.5.0: +esquery@^1.5.0: version "1.7.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== @@ -4491,11 +4251,6 @@ fast-diff@1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.1.2.tgz#4b62c42b8e03de3f848460b639079920695d0154" integrity sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig== -fast-equals@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-4.0.3.tgz#72884cc805ec3c6679b99875f6b7654f39f0e8c7" - integrity sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg== - fast-equals@^5.3.3: version "5.4.0" resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.4.0.tgz#b60073b8764f27029598447f05773c7534ba7f1e" @@ -4512,17 +4267,6 @@ fast-glob@3.3.1: merge2 "^1.3.0" micromatch "^4.0.4" -fast-glob@^3.2.9: - version "3.3.3" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" - integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.8" - fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -4566,13 +4310,6 @@ fflate@^0.8.1: resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A== -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== - dependencies: - flat-cache "^3.0.4" - file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -4607,15 +4344,6 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -flat-cache@^3.0.4: - version "3.2.0" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" - integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== - dependencies: - flatted "^3.2.9" - keyv "^4.5.3" - rimraf "^3.0.2" - flat-cache@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" @@ -4686,11 +4414,6 @@ formik@2.4.9: tiny-warning "^1.0.2" tslib "^2.0.0" -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - function-bind@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" @@ -4777,30 +4500,11 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@^7.1.3: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - globals@16.4.0: version "16.4.0" resolved "https://registry.yarnpkg.com/globals/-/globals-16.4.0.tgz#574bc7e72993d40cf27cf6c241f324ee77808e51" integrity sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw== -globals@^13.19.0: - version "13.24.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" - integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== - dependencies: - type-fest "^0.20.2" - globals@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" @@ -4814,18 +4518,6 @@ globalthis@^1.0.4: define-properties "^1.2.1" gopd "^1.0.1" -globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - goober@^2.1.16: version "2.1.18" resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.18.tgz#b72d669bd24d552d441638eee26dfd5716ea6442" @@ -4836,11 +4528,6 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graphemer@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" - integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== - gray-matter@4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.3.tgz#e893c064825de73ea1f5f7d88c7a9f7274288798" @@ -4851,13 +4538,6 @@ gray-matter@4.0.3: section-matter "^1.0.0" strip-bom-string "^1.0.0" -has-ansi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" - integrity sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg== - dependencies: - ansi-regex "^2.0.0" - has-bigints@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" @@ -5039,13 +4719,6 @@ hsl-to-rgb-for-reals@^1.1.0: resolved "https://registry.yarnpkg.com/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz#e1eb23f6b78016e3722431df68197e6dcdc016d9" integrity sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg== -html-parse-stringify@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" - integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== - dependencies: - void-elements "3.1.0" - html-tokenize@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/html-tokenize/-/html-tokenize-2.0.1.tgz#c3b2ea6e2837d4f8c06693393e9d2a12c960be5f" @@ -5092,13 +4765,6 @@ hyphen@^1.6.4: resolved "https://registry.yarnpkg.com/hyphen/-/hyphen-1.14.1.tgz#c9fbd5e1af750f00d5034aa37f6ec41f95ffed93" integrity sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw== -i18next@25.8.18: - version "25.8.18" - resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.8.18.tgz#51863b65bc42e3525271f2680ebbf7d150ff53cc" - integrity sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA== - dependencies: - "@babel/runtime" "^7.28.6" - ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" @@ -5132,20 +4798,7 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: +inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -5349,11 +5002,6 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-plain-obj@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" @@ -5567,7 +5215,7 @@ jspdf@^4.2.0: object.assign "^4.1.4" object.values "^1.1.6" -keyv@^4.5.3, keyv@^4.5.4: +keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== @@ -5591,11 +5239,6 @@ language-tags@^1.0.9: dependencies: language-subtag-registry "^0.3.20" -leaflet-defaulticon-compatibility@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.2.tgz#f5e1a5841aeab9d1682d17887348855a741b3c2a" - integrity sha512-IrKagWxkTwzxUkFIumy/Zmo3ksjuAu3zEadtOuJcKzuXaD76Gwvg2Z1mLyx7y52ykOzM8rAH5ChBs4DnfdGa6Q== - leaflet.markercluster@^1.5.3: version "1.5.3" resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz#9cdb52a4eab92671832e1ef9899669e80efc4056" @@ -5671,18 +5314,10 @@ lodash@^4.17.21, lodash@^4.17.4: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== -loglevel-colored-level-prefix@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz#6a40218fdc7ae15fc76c3d0f3e676c465388603e" - integrity sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA== - dependencies: - chalk "^1.1.3" - loglevel "^1.4.1" - -loglevel@^1.4.1: - version "1.9.2" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08" - integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== +lodash@^4.18.1: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== longest-streak@^3.0.0: version "3.1.0" @@ -5965,7 +5600,7 @@ memoize-one@^6.0.0: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== -merge2@^1.3.0, merge2@^1.4.1: +merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== @@ -6243,7 +5878,7 @@ micromark@^4.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" -micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.4: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -6263,13 +5898,6 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" -minimatch@9.0.3: - version "9.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== - dependencies: - brace-expansion "^2.0.1" - minimatch@^10.2.2: version "10.2.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" @@ -6277,7 +5905,7 @@ minimatch@^10.2.2: dependencies: brace-expansion "^5.0.2" -minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^3.1.5: +minimatch@^3.1.2, minimatch@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== @@ -6483,13 +6111,6 @@ object.values@^1.1.6, object.values@^1.2.1: define-properties "^1.2.1" es-object-atoms "^1.0.0" -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -6602,11 +6223,6 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" @@ -6666,38 +6282,11 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier-eslint@^16.4.2: - version "16.4.2" - resolved "https://registry.yarnpkg.com/prettier-eslint/-/prettier-eslint-16.4.2.tgz#d84bff76e0ce4a6ffccacacb2474f7635ca8ac35" - integrity sha512-vtJAQEkaN8fW5QKl08t7A5KCjlZuDUNeIlr9hgolMS5s3+uzbfRHDwaRnzrdqnY2YpHDmeDS/8zY0MKQHXJtaA== - dependencies: - "@typescript-eslint/parser" "^6.21.0" - common-tags "^1.8.2" - dlv "^1.1.3" - eslint "^8.57.1" - indent-string "^4.0.0" - lodash.merge "^4.6.2" - loglevel-colored-level-prefix "^1.0.0" - prettier "^3.5.3" - pretty-format "^29.7.0" - require-relative "^0.8.7" - tslib "^2.8.1" - vue-eslint-parser "^9.4.3" - -prettier@^3.5.3, prettier@^3.8.1: +prettier@^3.8.1: version "3.8.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== -pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== - dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - prismjs@^1.30.0: version "1.30.0" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9" @@ -6708,7 +6297,7 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -prop-types@15.8.1, prop-types@15.x, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@15.8.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -6966,14 +6555,6 @@ react-colorful@^5.6.1: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== -react-copy-to-clipboard@^5.1.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.1.tgz#76adb8be03616e99692fcf3f762365ed3fb5ff16" - integrity sha512-s+HrzLyJBxrpGTYXF15dTgMjAJpEPZT/Yp6NytAtZMRngejxt6Pt5WrfFxLAcsqUDU6sY1Jz6tyHwIicE1U2Xg== - dependencies: - copy-to-clipboard "^3.3.3" - prop-types "^15.8.1" - react-dom@19.2.5: version "19.2.5" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.5.tgz#b8768b10837d0b8e9ca5b9e2d58dff3d880ea25e" @@ -6981,14 +6562,6 @@ react-dom@19.2.5: dependencies: scheduler "^0.27.0" -react-draggable@^4.4.6, react-draggable@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.5.0.tgz#0b274ccb6965fcf97ed38fcf7e3cc223bc48cdf5" - integrity sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw== - dependencies: - clsx "^2.1.1" - prop-types "^15.8.1" - react-dropzone@15.0.0: version "15.0.0" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-15.0.0.tgz#bd03c7c2b14fe4ea9db1a9c74502b85339f2e505" @@ -7008,18 +6581,6 @@ react-fast-compare@^2.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-grid-layout@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-2.2.3.tgz#6daf24b8c48448af617238520dd233a9375e2f16" - integrity sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ== - dependencies: - clsx "^2.1.1" - fast-equals "^4.0.3" - prop-types "^15.8.1" - react-draggable "^4.4.6" - react-resizable "^3.1.3" - resize-observer-polyfill "^1.5.1" - react-hook-form@^7.72.0: version "7.72.0" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.72.0.tgz#995a655b894249fd8798f36383e43f55ed66ae25" @@ -7040,15 +6601,6 @@ react-html-parser@^2.0.2: dependencies: htmlparser2 "^3.9.0" -react-i18next@16.6.5: - version "16.6.5" - resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-16.6.5.tgz#6fd2b0b82ed6988b87e51487d53c28954994d361" - integrity sha512-bfdJhmyjQCXtU9CLcGMn3a1V5/jTeUX/x29cOhlS1Lolm/epRtm24gnYsltxArsc29ow3klSJEijjfYXc5kxjg== - dependencies: - "@babel/runtime" "^7.29.2" - html-parse-stringify "^3.0.1" - use-sync-external-store "^1.6.0" - react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -7059,11 +6611,6 @@ react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^18.0.0: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" - integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== - react-is@^19.2.3: version "19.2.4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.4.tgz#a080758243c572ccd4a63386537654298c99d135" @@ -7150,14 +6697,6 @@ react-redux@^7.2.0: prop-types "^15.7.2" react-is "^17.0.2" -react-resizable@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-3.1.3.tgz#b8c3f8aeffb7b0b2c2306bfc7a742462e58125fb" - integrity sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw== - dependencies: - prop-types "15.x" - react-draggable "^4.5.0" - react-syntax-highlighter@^16.1.0: version "16.1.1" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz#928459855d375f5cfc8e646071e20d541cebcb52" @@ -7199,11 +6738,6 @@ react-virtuoso@^4.18.5: resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.18.5.tgz#450108e585c7a1124b995c7ea3cf367ed4857631" integrity sha512-QDyNjyNEuurZG67SOmzYyxEkQYSyGmAMixOI6M15L/Q4CF39EgG+88y6DgZRo0q7rmy0HPx3Fj90I8/tPdnRCQ== -react-window@^2.2.7: - version "2.2.7" - resolved "https://registry.yarnpkg.com/react-window/-/react-window-2.2.7.tgz#7f3d31695d4323701b7e80dfc9bbbe1d4a0c160f" - integrity sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w== - react@19.2.5: version "19.2.5" resolved "https://registry.yarnpkg.com/react/-/react-19.2.5.tgz#c888ab8b8ef33e2597fae8bdb2d77edbdb42858b" @@ -7258,17 +6792,12 @@ recharts@^3.8.1: use-sync-external-store "^1.2.2" victory-vendor "^37.0.2" -redux-devtools-extension@2.13.9: - version "2.13.9" - resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.9.tgz#6b764e8028b507adcb75a1cae790f71e6be08ae7" - integrity sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A== - redux-persist@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== -redux-thunk@3.1.0, redux-thunk@^3.1.0: +redux-thunk@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== @@ -7428,21 +6957,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== -require-relative@^0.8.7: - version "0.8.7" - resolved "https://registry.yarnpkg.com/require-relative/-/require-relative-0.8.7.tgz#7999539fc9e047a37928fa196f8e1563dabd36de" - integrity sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg== - reselect@5.1.1, reselect@^5.1.0, reselect@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== -resize-observer-polyfill@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" - integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -7489,13 +7008,6 @@ rgbcolor@^1.0.1: resolved "https://registry.yarnpkg.com/rgbcolor/-/rgbcolor-1.0.1.tgz#d6505ecdb304a6595da26fa4b43307306775945d" integrity sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw== -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - rope-sequence@^1.3.0: version "1.3.4" resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425" @@ -7574,7 +7086,7 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.6, semver@^7.5.4, semver@^7.7.1, semver@^7.7.3: +semver@^7.7.1, semver@^7.7.3: version "7.7.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -7725,11 +7237,6 @@ simplebar@6.3.3: dependencies: simplebar-core "^1.3.2" -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - snake-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" @@ -7876,20 +7383,6 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -strip-ansi@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg== - dependencies: - ansi-regex "^2.0.0" - -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-bom-string@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" @@ -7938,11 +7431,6 @@ stylis@4.2.0: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== -supports-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" - integrity sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g== - supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -7990,11 +7478,6 @@ text-segmentation@^1.0.3: dependencies: utrie "^1.0.2" -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - through2@~0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/through2/-/through2-0.4.2.tgz#dbf5866031151ec8352bb6c4db64a2292a840b9b" @@ -8043,11 +7526,6 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -toggle-selection@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" - integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== - toposort@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" @@ -8063,11 +7541,6 @@ trough@^2.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== -ts-api-utils@^1.0.1: - version "1.4.3" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064" - integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== - ts-api-utils@^2.4.0: version "2.5.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" @@ -8083,7 +7556,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.0.0, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: +tslib@^2.0.0, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -8095,11 +7568,6 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - type-fest@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" @@ -8160,11 +7628,6 @@ typescript-eslint@^8.46.0: "@typescript-eslint/typescript-estree" "8.57.1" "@typescript-eslint/utils" "8.57.1" -typescript@5.9.3: - version "5.9.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" - integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== - uc.micro@^2.0.0, uc.micro@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" @@ -8224,7 +7687,7 @@ unicode-trie@^2.0.0: pako "^0.2.5" tiny-inflate "^1.0.0" -unified@^11.0.0: +unified@^11.0.0, unified@^11.0.5: version "11.0.5" resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== @@ -8397,24 +7860,6 @@ vite-compatible-readable-stream@^3.6.1: string_decoder "^1.1.1" util-deprecate "^1.0.1" -void-elements@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" - integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== - -vue-eslint-parser@^9.4.3: - version "9.4.3" - resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz#9b04b22c71401f1e8bca9be7c3e3416a4bde76a8" - integrity sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg== - dependencies: - debug "^4.3.4" - eslint-scope "^7.1.1" - eslint-visitor-keys "^3.3.0" - espree "^9.3.1" - esquery "^1.4.0" - lodash "^4.17.21" - semver "^7.3.6" - w3c-keyname@^2.2.0: version "2.2.8" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" @@ -8490,11 +7935,6 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - xtend@~2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-2.1.2.tgz#6efecc2a4dad8e6962c4901b337ce7ba87b5d28b" From c0946109c91b31ee9699ba7dbf377a8f299b1a55 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 13 May 2026 16:47:42 +0800 Subject: [PATCH 045/164] Fix bulk mailbox rule changes --- .../administration/users/user/exchange.jsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 1d01f699c5a7..1336a740612d 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -947,13 +947,15 @@ const Page = () => { icon: , url: "/api/ExecSetMailboxRule", customDataformatter: (row, action, formData) => { - return { - ruleId: row?.Identity, + const rows = Array.isArray(row) ? row : [row]; + const result = rows.map((r) => ({ + ruleId: r?.Identity, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, - ruleName: row?.Name, + ruleName: r?.Name, Enable: true, tenantFilter: userSettingsDefaults.currentTenant, - }; + })); + return Array.isArray(row) ? result : result[0]; }, condition: (row) => row && !row.Enabled, confirmText: "Are you sure you want to enable this mailbox rule?", @@ -965,13 +967,15 @@ const Page = () => { icon: , url: "/api/ExecSetMailboxRule", customDataformatter: (row, action, formData) => { - return { - ruleId: row?.Identity, + const rows = Array.isArray(row) ? row : [row]; + const result = rows.map((r) => ({ + ruleId: r?.Identity, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, - ruleName: row?.Name, + ruleName: r?.Name, Disable: true, tenantFilter: userSettingsDefaults.currentTenant, - }; + })); + return Array.isArray(row) ? result : result[0]; }, condition: (row) => row && row.Enabled, confirmText: "Are you sure you want to disable this mailbox rule?", @@ -983,12 +987,14 @@ const Page = () => { icon: , url: "/api/ExecRemoveMailboxRule", customDataformatter: (row, action, formData) => { - return { - ruleId: row?.Identity, - ruleName: row?.Name, + const rows = Array.isArray(row) ? row : [row]; + const result = rows.map((r) => ({ + ruleId: r?.Identity, + ruleName: r?.Name, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, - }; + })); + return Array.isArray(row) ? result : result[0]; }, confirmText: "Are you sure you want to remove this mailbox rule?", multiPost: false, From 72d8658d5ded1c0f47fddeacb3a384c5cbf0e38d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 13 May 2026 17:06:41 +0800 Subject: [PATCH 046/164] Add Apps and SP to universal search --- .../CippCards/CippUniversalSearchV2.jsx | 29 +++++++++++++++++++ src/components/bulk-actions-menu.js | 4 ++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index 28a53f35ef82..070396f0a56e 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -348,6 +348,16 @@ export const CippUniversalSearchV2 = React.forwardRef( router.push( `/identity/administration/groups/group?groupId=${itemData.id}&tenantFilter=${tenantDomain}`, ); + } else if (searchType === "Applications") { + if (match.Type === "Apps") { + router.push( + `/tenant/administration/applications/app-registration?appId=${itemData.appId || itemData.id}&tenantFilter=${tenantDomain}`, + ); + } else { + router.push( + `/tenant/administration/applications/enterprise-app?spId=${itemData.id}&tenantFilter=${tenantDomain}`, + ); + } } else if (searchType === "Pages") { router.push(match.path, undefined, { shallow: true }); } @@ -389,6 +399,11 @@ export const CippUniversalSearchV2 = React.forwardRef( icon: "Group", onClick: () => handleTypeChange("Groups"), }, + { + label: "Applications", + icon: "Apps", + onClick: () => handleTypeChange("Applications"), + }, { label: "BitLocker", icon: "FilePresent", @@ -730,6 +745,20 @@ const Results = ({ )} )} + {searchType === "Applications" && ( + <> + {itemData.appId && ( + + {highlightMatch(itemData.appId || "")} + + )} + {itemData.publisherName && ( + + {highlightMatch(itemData.publisherName || "")} + + )} + + )} ; case "Group": return ; + case "Apps": + return ; default: return null; } From ba196dde059e16c53ee182a7d49eff8292073e27 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 13 May 2026 12:07:00 +0200 Subject: [PATCH 047/164] expand side nav slightly for ux --- .claude/worktrees/blissful-golick-d405ab | 1 + src/layouts/side-nav.js | 174 +++++++++++------------ 2 files changed, 88 insertions(+), 87 deletions(-) create mode 160000 .claude/worktrees/blissful-golick-d405ab diff --git a/.claude/worktrees/blissful-golick-d405ab b/.claude/worktrees/blissful-golick-d405ab new file mode 160000 index 000000000000..0710355e2ada --- /dev/null +++ b/.claude/worktrees/blissful-golick-d405ab @@ -0,0 +1 @@ +Subproject commit 0710355e2adac37fffe4c7eef48d6f2c3a04993d diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index ec43e9fb857f..5b01ee107331 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -1,64 +1,64 @@ -import { useState, useRef, useEffect } from "react"; -import { usePathname } from "next/navigation"; -import PropTypes from "prop-types"; -import { Box, Divider, Drawer, Stack } from "@mui/material"; -import { SideNavItem } from "./side-nav-item"; -import { SideNavBookmarks } from "./side-nav-bookmarks"; -import { ApiGetCall } from "../api/ApiCall.jsx"; -import { CippSponsor } from "../components/CippComponents/CippSponsor"; -import { useSettings } from "../hooks/use-settings"; - -const SIDE_NAV_WIDTH = 270; -const SIDE_NAV_COLLAPSED_WIDTH = 73; // icon size + padding + border right -const TOP_NAV_HEIGHT = 64; +import { useState, useRef, useEffect } from 'react' +import { usePathname } from 'next/navigation' +import PropTypes from 'prop-types' +import { Box, Divider, Drawer, Stack } from '@mui/material' +import { SideNavItem } from './side-nav-item' +import { SideNavBookmarks } from './side-nav-bookmarks' +import { ApiGetCall } from '../api/ApiCall.jsx' +import { CippSponsor } from '../components/CippComponents/CippSponsor' +import { useSettings } from '../hooks/use-settings' + +const SIDE_NAV_WIDTH = 290 +const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right +const TOP_NAV_HEIGHT = 64 const isPathPrefix = (pathname, itemPath) => { - if (!pathname || !itemPath) return false; - if (pathname === itemPath) return true; + if (!pathname || !itemPath) return false + if (pathname === itemPath) return true // Root "/" maps to /dashboardv2 under the hood - if (itemPath === "/") return pathname.startsWith("/dashboardv2"); - return pathname.startsWith(itemPath + "/") || pathname.startsWith(itemPath + "?"); -}; + if (itemPath === '/') return pathname.startsWith('/dashboardv2') + return pathname.startsWith(itemPath + '/') || pathname.startsWith(itemPath + '?') +} const markOpenItems = (items, pathname) => { return items.map((item) => { - const checkPath = !!(item.path && pathname); - const exactMatch = checkPath ? pathname === item.path : false; - const partialMatch = checkPath ? isPathPrefix(pathname, item.path) : false; + const checkPath = !!(item.path && pathname) + const exactMatch = checkPath ? pathname === item.path : false + const partialMatch = checkPath ? isPathPrefix(pathname, item.path) : false - let openImmediately = exactMatch; - let newItems = item.items || []; + let openImmediately = exactMatch + let newItems = item.items || [] if (newItems.length > 0) { - newItems = markOpenItems(newItems, pathname); - const childOpen = newItems.some((child) => child.openImmediately); - openImmediately = openImmediately || childOpen || exactMatch; // Ensure parent opens if child is open + newItems = markOpenItems(newItems, pathname) + const childOpen = newItems.some((child) => child.openImmediately) + openImmediately = openImmediately || childOpen || exactMatch // Ensure parent opens if child is open } else { - openImmediately = openImmediately || partialMatch; // Leaf items open on partial match + openImmediately = openImmediately || partialMatch // Leaf items open on partial match } return { ...item, items: newItems, openImmediately, - }; - }); -}; + } + }) +} -const renderItems = ({ collapse = false, depth = 0, items, pathname, category = "" }) => +const renderItems = ({ collapse = false, depth = 0, items, pathname, category = '' }) => items.reduce( (acc, item) => reduceChildRoutes({ acc, collapse, depth, item, pathname, category }), [] - ); + ) const reduceChildRoutes = ({ acc, collapse, depth, item, pathname, category }) => { - const checkPath = !!(item.path && pathname); - const exactMatch = checkPath && pathname === item.path; - const partialMatch = checkPath ? isPathPrefix(pathname, item.path) : false; + const checkPath = !!(item.path && pathname) + const exactMatch = checkPath && pathname === item.path + const partialMatch = checkPath ? isPathPrefix(pathname, item.path) : false - const hasChildren = item.items && item.items.length > 0; - const isActive = exactMatch || (partialMatch && !hasChildren); - const currentCategory = depth === 0 && item.type === "header" ? item.title : category; + const hasChildren = item.items && item.items.length > 0 + const isActive = exactMatch || (partialMatch && !hasChildren) + const currentCategory = depth === 0 && item.type === 'header' ? item.title : category if (hasChildren) { acc.push( @@ -80,7 +80,7 @@ const reduceChildRoutes = ({ acc, collapse, depth, item, pathname, category }) = component="ul" spacing={0.5} sx={{ - listStyle: "none", + listStyle: 'none', m: 0, p: 0, }} @@ -94,7 +94,7 @@ const reduceChildRoutes = ({ acc, collapse, depth, item, pathname, category }) = })} - ); + ) } else { acc.push( - ); + ) } - return acc; -}; + return acc +} export const SideNav = (props) => { - const { items, onPin, pinned = false } = props; - const pathname = usePathname(); - const [hovered, setHovered] = useState(false); - const collapse = !(pinned || hovered); - const { data: profile } = ApiGetCall({ url: "/api/me", queryKey: "authmecipp" }); - const settings = useSettings(); - const showSidebarBookmarks = settings.bookmarkSidebar !== false; - const paperRef = useRef(null); + const { items, onPin, pinned = false } = props + const pathname = usePathname() + const [hovered, setHovered] = useState(false) + const collapse = !(pinned || hovered) + const { data: profile } = ApiGetCall({ url: '/api/me', queryKey: 'authmecipp' }) + const settings = useSettings() + const showSidebarBookmarks = settings.bookmarkSidebar !== false + const paperRef = useRef(null) // Intercept wheel events on the side nav to fully isolate scroll. // preventDefault stops wheel events from reaching the main content, // and manual scrollTop has no momentum so it stops instantly when the cursor leaves. // Uses RAF-based easing to smooth out discrete mouse wheel jumps. useEffect(() => { - const el = paperRef.current; - if (!el) return; + const el = paperRef.current + if (!el) return - let targetScrollTop = el.scrollTop; - let animating = false; + let targetScrollTop = el.scrollTop + let animating = false const animate = () => { - const diff = targetScrollTop - el.scrollTop; + const diff = targetScrollTop - el.scrollTop if (Math.abs(diff) < 0.5) { - el.scrollTop = targetScrollTop; - animating = false; - return; + el.scrollTop = targetScrollTop + animating = false + return } - el.scrollTop += diff * 0.25; - requestAnimationFrame(animate); - }; + el.scrollTop += diff * 0.25 + requestAnimationFrame(animate) + } const handleWheel = (e) => { - e.preventDefault(); - const maxScroll = el.scrollHeight - el.clientHeight; - targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop + e.deltaY)); + e.preventDefault() + const maxScroll = el.scrollHeight - el.clientHeight + targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop + e.deltaY)) if (!animating) { - animating = true; - requestAnimationFrame(animate); + animating = true + requestAnimationFrame(animate) } - }; + } - el.addEventListener("wheel", handleWheel, { passive: false }); - return () => el.removeEventListener("wheel", handleWheel); - }, []); + el.addEventListener('wheel', handleWheel, { passive: false }) + return () => el.removeEventListener('wheel', handleWheel) + }, []) // Preprocess items to mark which should be open - const processedItems = markOpenItems(items, pathname); + const processedItems = markOpenItems(items, pathname) return ( <> {profile?.clientPrincipal && profile?.clientPrincipal?.userRoles?.length > 2 && ( @@ -174,13 +174,13 @@ export const SideNav = (props) => { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), sx: { - backgroundColor: "background.default", + backgroundColor: 'background.default', height: `calc(100% - ${TOP_NAV_HEIGHT}px)`, - overflowX: "hidden", - overflowY: "auto", - scrollbarGutter: "stable", + overflowX: 'hidden', + overflowY: 'auto', + scrollbarGutter: 'stable', top: TOP_NAV_HEIGHT, - transition: "width 250ms ease-in-out", + transition: 'width 250ms ease-in-out', width: collapse ? SIDE_NAV_COLLAPSED_WIDTH : SIDE_NAV_WIDTH, zIndex: (theme) => theme.zIndex.appBar - 100, }, @@ -189,9 +189,9 @@ export const SideNav = (props) => { @@ -199,7 +199,7 @@ export const SideNav = (props) => { component="ul" sx={{ flexGrow: 1, - listStyle: "none", + listStyle: 'none', m: 0, p: 0, }} @@ -218,24 +218,24 @@ export const SideNav = (props) => { items: processedItems, pathname, })} - {" "} + {' '} {/* Add this closing tag */} {profile?.clientPrincipal && ( )} - {" "} + {' '} {/* Closing tag for the parent Box */} )} - ); -}; + ) +} SideNav.propTypes = { onPin: PropTypes.func, pinned: PropTypes.bool, -}; +} From 52a4763907144faafc70dfeb439c705679e24dc0 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 13 May 2026 18:52:11 +0800 Subject: [PATCH 048/164] Nice CA policy editor and template creator/editor --- .../CippComponents/CippCAPolicyBuilder.jsx | 1121 +++++++++++++++++ .../CippTemplateFieldRenderer.jsx | 46 +- src/data/conditionalAccessSchema.json | 664 ++++++++++ .../tenant/conditional/list-policies/edit.jsx | 75 ++ .../tenant/conditional/list-policies/index.js | 7 + .../conditional/list-template/create.jsx | 39 + .../tenant/conditional/list-template/edit.jsx | 42 +- .../tenant/conditional/list-template/index.js | 11 +- 8 files changed, 1998 insertions(+), 7 deletions(-) create mode 100644 src/components/CippComponents/CippCAPolicyBuilder.jsx create mode 100644 src/data/conditionalAccessSchema.json create mode 100644 src/pages/tenant/conditional/list-policies/edit.jsx create mode 100644 src/pages/tenant/conditional/list-template/create.jsx diff --git a/src/components/CippComponents/CippCAPolicyBuilder.jsx b/src/components/CippComponents/CippCAPolicyBuilder.jsx new file mode 100644 index 000000000000..c7999edff6c6 --- /dev/null +++ b/src/components/CippComponents/CippCAPolicyBuilder.jsx @@ -0,0 +1,1121 @@ +import React, { useMemo, useCallback, useEffect } from "react"; +import { + Typography, + Divider, + Alert, + Chip, + Accordion, + AccordionSummary, + AccordionDetails, + Stack, + Tooltip, + IconButton, + Paper, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import { useWatch } from "react-hook-form"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; +import caSchema from "../../data/conditionalAccessSchema.json"; +import gdapRoles from "../../data/GDAPRoles.json"; + +/** + * CippCAPolicyBuilder — A schema-driven Conditional Access policy builder. + * + * Renders structured form sections for every CA policy property, with: + * - Enum validation via the Microsoft Graph v1.0 schema + * - Friendly labels sourced from the schema's enumLabels + * - Licence requirement indicators (P2 for risk fields) + * - Grant control constraint validation (block vs other controls) + * - Accordion sections matching the Entra admin centre layout + * + * Props: + * formControl — react-hook-form's return from useForm() + * existingPolicy — optional JSON to pre-populate fields (edit mode) + * disabled — optional boolean to make the form read-only + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Resolve a $ref path like "#/$defs/conditionalAccessUsers" in the schema */ +function resolveRef(ref) { + if (!ref) return null; + const path = ref.replace("#/", "").split("/"); + let node = caSchema; + for (const segment of path) { + node = node?.[segment]; + } + return node ?? null; +} + +/** Convert schema enum + enumLabels into {label, value} options */ +function enumToOptions(schemaProp) { + if (!schemaProp) return []; + const enumVals = schemaProp.items?.enum ?? schemaProp.enum ?? []; + const labels = schemaProp.items?.enumLabels ?? schemaProp.enumLabels ?? {}; + return enumVals.map((v) => ({ + label: labels[v] ?? v, + value: v, + })); +} + +/** Build options from wellKnownValues or wellKnownDirectoryRoles */ +function wellKnownToOptions(values) { + if (!values) return []; + return Object.entries(values).map(([id, label]) => ({ label: `${label}`, value: id })); +} + +/** Build special-value options from schema metadata */ +function specialValueOptions(schemaProp) { + const vals = schemaProp?.specialValues ?? []; + const labels = schemaProp?.specialValueLabels ?? {}; + return vals.map((v) => ({ label: labels[v] ?? v, value: v })); +} + +// --------------------------------------------------------------------------- +// Sub-section renderers +// --------------------------------------------------------------------------- + +function SectionHeader({ title, description, requiresLicense, icon }) { + return ( + + {icon} + {title} + {requiresLicense && ( + + + + )} + {description && ( + + + + + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Users & Groups section +// --------------------------------------------------------------------------- +function UsersSection({ formControl, disabled, prefix = "conditions.users" }) { + const schemaDef = resolveRef("#/$defs/conditionalAccessUsers"); + const guestSchema = resolveRef("#/$defs/conditionalAccessGuestsOrExternalUsers"); + const roleOptions = useMemo( + () => gdapRoles.map((r) => ({ label: r.Name, value: r.ObjectId })), + [] + ); + const specialUserOpts = useMemo( + () => specialValueOptions(schemaDef?.properties?.includeUsers), + [schemaDef] + ); + + const guestTypeOpts = useMemo(() => { + const prop = guestSchema?.properties?.guestOrExternalUserTypes; + const flags = prop?.flagEnum ?? []; + const labels = prop?.flagEnumLabels ?? {}; + return flags + .filter((f) => f !== "none") + .map((f) => ({ label: labels[f] ?? f, value: f })); + }, [guestSchema]); + + return ( + + {/* Include users */} + + + + {/* Exclude users */} + + + + {/* Include groups */} + + + + {/* Exclude groups */} + + + + {/* Include roles */} + + + + {/* Exclude roles */} + + + + + {/* Guest / External User Exclusions */} + + + + Exclude Guests or External Users + + + + + + + Select one or more external user types to exclude from this policy. + + + + + + + Choose whether the exclusion applies to all external tenants or specific ones. Only + relevant for external user types (not internal guests). + + + + + + + Enter the tenant IDs to scope this exclusion to (e.g. your partner tenant ID for + service provider exclusion). + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Applications section +// --------------------------------------------------------------------------- +function ApplicationsSection({ formControl, disabled, prefix = "conditions.applications" }) { + const schemaDef = resolveRef("#/$defs/conditionalAccessApplications"); + const includeAppOpts = useMemo( + () => specialValueOptions(schemaDef?.properties?.includeApplications), + [schemaDef] + ); + const userActionOpts = useMemo( + () => enumToOptions(schemaDef?.properties?.includeUserActions), + [schemaDef] + ); + + return ( + + + + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Conditions (client apps, platforms, locations, risk, etc.) +// --------------------------------------------------------------------------- +function ConditionsSection({ formControl, disabled }) { + const condSchema = resolveRef("#/$defs/conditionalAccessConditionSet"); + const platformSchema = resolveRef("#/$defs/conditionalAccessPlatforms"); + const authFlowSchema = resolveRef("#/$defs/conditionalAccessAuthenticationFlows"); + + const clientAppOpts = useMemo( + () => enumToOptions(condSchema?.properties?.clientAppTypes), + [condSchema] + ); + const includePlatOpts = useMemo( + () => enumToOptions(platformSchema?.properties?.includePlatforms), + [platformSchema] + ); + const excludePlatOpts = useMemo( + () => enumToOptions(platformSchema?.properties?.excludePlatforms), + [platformSchema] + ); + const signInRiskOpts = useMemo( + () => enumToOptions(condSchema?.properties?.signInRiskLevels), + [condSchema] + ); + const userRiskOpts = useMemo( + () => enumToOptions(condSchema?.properties?.userRiskLevels), + [condSchema] + ); + const spRiskOpts = useMemo( + () => enumToOptions(condSchema?.properties?.servicePrincipalRiskLevels), + [condSchema] + ); + const insiderRiskOpts = useMemo( + () => enumToOptions(condSchema?.properties?.insiderRiskLevels), + [condSchema] + ); + const authFlowOpts = useMemo( + () => enumToOptions(authFlowSchema?.properties?.transferMethods), + [authFlowSchema] + ); + + const locationSchema = resolveRef("#/$defs/conditionalAccessLocations"); + const includeLocOpts = useMemo( + () => specialValueOptions(locationSchema?.properties?.includeLocations), + [locationSchema] + ); + const excludeLocOpts = useMemo( + () => specialValueOptions(locationSchema?.properties?.excludeLocations), + [locationSchema] + ); + + return ( + + {/* Client app types */} + + + + + {/* Platforms */} + + + + + + + + {/* Locations */} + + + + + + + + {/* Device filter */} + + + + Device Filter + + + + + + + + + + + {/* Risk levels */} + + + + + Risk Levels + + + + + + + + + + + + + + + + {/* Insider risk */} + + + + + {/* Auth flows */} + + + + + ); +} + +// --------------------------------------------------------------------------- +// Grant Controls section +// --------------------------------------------------------------------------- +function GrantControlsSection({ formControl, disabled }) { + const grantSchema = resolveRef("#/$defs/conditionalAccessGrantControls"); + const operatorOpts = useMemo( + () => enumToOptions(grantSchema?.properties?.operator), + [grantSchema] + ); + const builtInOpts = useMemo( + () => enumToOptions(grantSchema?.properties?.builtInControls), + [grantSchema] + ); + + const authStrengthSchema = resolveRef("#/$defs/authenticationStrengthPolicy"); + const authStrengthOpts = useMemo( + () => wellKnownToOptions(authStrengthSchema?.properties?.id?.wellKnownValues), + [authStrengthSchema] + ); + + const selectedControls = useWatch({ + control: formControl.control, + name: "grantControls.builtInControls", + }); + + const hasBlock = useMemo(() => { + if (!selectedControls) return false; + return (Array.isArray(selectedControls) ? selectedControls : [selectedControls]).some( + (c) => (c?.value ?? c) === "block" + ); + }, [selectedControls]); + + return ( + + + + + + + {hasBlock && ( + + "Block access" cannot be combined with other grant controls. All other + selections will be ignored by Entra ID. + + )} + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Session Controls section +// --------------------------------------------------------------------------- +function SessionControlsSection({ formControl, disabled }) { + const casSchema = resolveRef("#/$defs/cloudAppSecuritySessionControl"); + const casTypeOpts = useMemo( + () => enumToOptions(casSchema?.properties?.cloudAppSecurityType), + [casSchema] + ); + + const signinSchema = resolveRef("#/$defs/signInFrequencySessionControl"); + const freqTypeOpts = useMemo( + () => enumToOptions(signinSchema?.properties?.type), + [signinSchema] + ); + const freqIntervalOpts = useMemo( + () => enumToOptions(signinSchema?.properties?.frequencyInterval), + [signinSchema] + ); + const freqAuthTypeOpts = useMemo( + () => enumToOptions(signinSchema?.properties?.authenticationType), + [signinSchema] + ); + + const persistSchema = resolveRef("#/$defs/persistentBrowserSessionControl"); + const persistModeOpts = useMemo( + () => enumToOptions(persistSchema?.properties?.mode), + [persistSchema] + ); + + return ( + + {/* App enforced restrictions */} + + + Application Enforced Restrictions + + + Only Exchange Online and SharePoint Online support this control. + + + + + + + {/* Cloud App Security */} + + + Conditional Access App Control + + + + + + + + + {/* Sign-in frequency */} + + + Sign-in Frequency + + + + + + + + + + + + + + + + + + {/* Persistent browser */} + + + Persistent Browser Session + + + + + + + + + {/* Resilience defaults */} + + + + + + + When enabled, Entra ID will not extend existing sessions during outages. + + + + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- +const CippCAPolicyBuilder = ({ formControl, existingPolicy, disabled = false }) => { + const policySchema = caSchema; + + // Pre-populate form from existing policy when editing + useEffect(() => { + if (existingPolicy && formControl) { + const populate = (obj, prefix = "") => { + if (!obj || typeof obj !== "object") return; + Object.entries(obj).forEach(([key, value]) => { + // Skip read-only / OData / internal / Graph metadata properties + if ( + (key === "id" && !prefix) || // Only skip top-level policy id + key === "createdDateTime" || + key === "modifiedDateTime" || + key === "deletedDateTime" || + key === "templateId" || + key === "partialEnablementStrategy" || + key.includes("@odata") || // Catch both @odata.type and includePlatforms@odata.type + key.startsWith("#") || // Catch #microsoft.graph.restore etc. + key === "GUID" || + key === "source" || + key === "isSynced" || + key === "package" + ) { + return; + } + // Skip null, empty arrays, and empty strings — treat as "not set" + if (value === null || value === undefined) return; + if (Array.isArray(value) && value.length === 0) return; + if (typeof value === "string" && value.trim() === "") return; + + const path = prefix ? `${prefix}.${key}` : key; + + // Special handling for authenticationStrength — only extract the policy ID, + // not the full expanded object (displayName, description, allowedCombinations, etc.) + if (key === "authenticationStrength" && typeof value === "object" && !Array.isArray(value)) { + if (value.id) { + formControl.setValue(`${path}.id`, value.id); + } + return; + } + + // Special handling for guestOrExternalUserTypes — Graph stores as comma-separated + // string but our form uses a multi-select array + if (key === "guestOrExternalUserTypes" && typeof value === "string") { + const types = value.split(",").filter((t) => t.trim() !== "" && t !== "none"); + if (types.length > 0) { + formControl.setValue(path, types); + } + return; + } + + // Special handling for externalTenants — extract members and set _scope + if (key === "externalTenants" && typeof value === "object" && !Array.isArray(value)) { + if (value.members && Array.isArray(value.members) && value.members.length > 0) { + formControl.setValue(`${path}.members`, value.members); + formControl.setValue(`${path}._scope`, { label: "Specific tenants", value: "enumerated" }); + } else { + formControl.setValue(`${path}._scope`, { label: "All external tenants", value: "all" }); + } + return; + } + + if (typeof value === "object" && !Array.isArray(value)) { + populate(value, path); + } else { + formControl.setValue(path, value); + } + }); + }; + populate(existingPolicy); + } + }, [existingPolicy, formControl]); + + // Schema-level validation: extract options for top-level policy state + const stateOpts = useMemo( + () => enumToOptions(policySchema.properties.state), + [policySchema] + ); + + return ( + + {/* Policy basics */} + + + + + + + + + + + + + {/* Users & Groups */} + + }> + + Users and Groups + + + + + + + + {/* Cloud Apps or Actions */} + + }> + + Cloud Apps or Actions + + + + + + + + {/* Conditions */} + + }> + + Conditions + + + + + + + + {/* Grant Controls */} + + }> + + Grant Controls + + + + + + + + {/* Session Controls */} + + }> + + Session Controls + + + + + + + + ); +}; + +export default CippCAPolicyBuilder; + +/** + * Utility: extract a clean CA policy JSON from react-hook-form values. + * + * Call this in your form's submit handler to strip out { label, value } + * wrapper objects from autoComplete fields, remove empty/null branches, + * and ensure the JSON is ready to send to AddCAPolicy / AddCATemplate. + */ +export function extractCAPolicyJSON(formValues) { + const clean = (obj) => { + if (obj === null || obj === undefined) return undefined; + + // Unwrap {label,value} from autoComplete + if (typeof obj === "object" && "value" in obj && "label" in obj) { + return obj.value; + } + + if (Array.isArray(obj)) { + const arr = obj.map(clean).filter((v) => v !== undefined && v !== null && v !== ""); + return arr.length > 0 ? arr : undefined; + } + + if (typeof obj === "object") { + const result = {}; + let hasContent = false; + for (const [key, value] of Object.entries(obj)) { + // Strip internal builder fields (e.g. _scope) + if (key.startsWith("_")) continue; + // Strip OData annotations EXCEPT @odata.type (required by Graph for polymorphic types) + if (key === "@odata.type") { + result[key] = value; + hasContent = true; + continue; + } + if (key.includes("@odata") || key.startsWith("#")) continue; + + const cleaned = clean(value); + if (cleaned !== undefined) { + result[key] = cleaned; + hasContent = true; + } + } + return hasContent ? result : undefined; + } + + // Booleans, numbers, non-empty strings pass through + if (typeof obj === "string" && obj.trim() === "") return undefined; + return obj; + }; + + const cleaned = clean(formValues) ?? {}; + + // Post-process: fix guestsOrExternalUsers structures for Graph API + const fixGuestExternalUsers = (guestObj) => { + if (!guestObj) return guestObj; + // Graph expects guestOrExternalUserTypes as a comma-separated string + if (Array.isArray(guestObj.guestOrExternalUserTypes)) { + guestObj.guestOrExternalUserTypes = guestObj.guestOrExternalUserTypes.join(","); + } + // Determine scope from the internal _scope field or from members presence + const scope = guestObj.externalTenants?._scope; + const hasMembers = + guestObj.externalTenants?.members && guestObj.externalTenants.members.length > 0; + + if (guestObj.externalTenants) { + // Remove internal _scope field + delete guestObj.externalTenants._scope; + + if (scope === "enumerated" || hasMembers) { + guestObj.externalTenants["@odata.type"] = + "#microsoft.graph.conditionalAccessEnumeratedExternalTenants"; + guestObj.externalTenants.membershipKind = "enumerated"; + } else { + guestObj.externalTenants = { + "@odata.type": "#microsoft.graph.conditionalAccessAllExternalTenants", + membershipKind: "all", + }; + } + } else if (guestObj.guestOrExternalUserTypes) { + // No tenants specified — default to all external tenants + guestObj.externalTenants = { + "@odata.type": "#microsoft.graph.conditionalAccessAllExternalTenants", + membershipKind: "all", + }; + } + return guestObj; + }; + + if (cleaned.conditions?.users?.excludeGuestsOrExternalUsers) { + cleaned.conditions.users.excludeGuestsOrExternalUsers = fixGuestExternalUsers( + cleaned.conditions.users.excludeGuestsOrExternalUsers + ); + } + if (cleaned.conditions?.users?.includeGuestsOrExternalUsers) { + cleaned.conditions.users.includeGuestsOrExternalUsers = fixGuestExternalUsers( + cleaned.conditions.users.includeGuestsOrExternalUsers + ); + } + + return cleaned; +} diff --git a/src/components/CippComponents/CippTemplateFieldRenderer.jsx b/src/components/CippComponents/CippTemplateFieldRenderer.jsx index 1757e400acda..8ecef26973eb 100644 --- a/src/components/CippComponents/CippTemplateFieldRenderer.jsx +++ b/src/components/CippComponents/CippTemplateFieldRenderer.jsx @@ -244,11 +244,43 @@ const CippTemplateFieldRenderer = ({ React.useEffect(() => { if (templateData && formControl) { const processedData = parseIntuneRawJson(templateData); - const formValues = {}; + // Recursively strip null values, empty arrays, empty strings, + // and @odata / Graph metadata keys so they don't create blank + // form fields or phantom sections in the builder. + const stripEmpty = (obj) => { + if (obj === null || obj === undefined) return undefined; + if (typeof obj === "string" && obj.trim() === "") return undefined; + if (Array.isArray(obj)) { + const filtered = obj + .map(stripEmpty) + .filter((v) => v !== undefined && v !== null); + return filtered.length > 0 ? filtered : undefined; + } + if (typeof obj === "object") { + const result = {}; + let hasContent = false; + for (const [k, v] of Object.entries(obj)) { + // Drop @odata annotations and Graph metadata + if (k.includes("@odata") || k.startsWith("#")) continue; + const cleaned = stripEmpty(v); + if (cleaned !== undefined) { + result[k] = cleaned; + hasContent = true; + } + } + return hasContent ? result : undefined; + } + return obj; + }; + + const formValues = {}; Object.keys(processedData).forEach((key) => { if (!isFieldBlacklisted(key)) { - formValues[key] = processedData[key]; + const cleaned = stripEmpty(processedData[key]); + if (cleaned !== undefined) { + formValues[key] = cleaned; + } } }); formControl.reset(formValues); @@ -258,6 +290,10 @@ const CippTemplateFieldRenderer = ({ const renderFormField = (key, value, path = "") => { const fieldPath = path ? `${path}.${key}` : key; + // Skip null/undefined values and @odata / metadata keys + if (value === null || value === undefined) return null; + if (key.includes("@odata") || key.startsWith("#")) return null; + if (isFieldBlacklisted(key)) { return null; } @@ -776,12 +812,16 @@ const CippTemplateFieldRenderer = ({ {priorityFields.map( (fieldName) => processedData[fieldName] !== undefined && + processedData[fieldName] !== null && renderFormField(fieldName, processedData[fieldName]) )} {/* Render all other fields except priority fields */} {Object.entries(processedData) - .filter(([key]) => !priorityFields.includes(key)) + .filter( + ([key, value]) => + !priorityFields.includes(key) && value !== null && value !== undefined + ) .map(([key, value]) => renderFormField(key, value))} ); diff --git a/src/data/conditionalAccessSchema.json b/src/data/conditionalAccessSchema.json new file mode 100644 index 000000000000..e6161261854e --- /dev/null +++ b/src/data/conditionalAccessSchema.json @@ -0,0 +1,664 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "microsoft.graph.conditionalAccessPolicy", + "title": "Conditional Access Policy", + "description": "Schema derived from the Microsoft Graph v1.0 conditionalAccessPolicy resource type. Reference: https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", + "type": "object", + "required": ["displayName", "state", "conditions"], + "properties": { + "displayName": { + "type": "string", + "title": "Display Name", + "description": "A display name for the policy.", + "minLength": 1, + "maxLength": 256 + }, + "state": { + "type": "string", + "title": "Policy State", + "description": "The state of the policy.", + "enum": ["enabled", "disabled", "enabledForReportingButNotEnforced"], + "enumLabels": { + "enabled": "Enabled", + "disabled": "Disabled", + "enabledForReportingButNotEnforced": "Report-only" + } + }, + "conditions": { + "$ref": "#/$defs/conditionalAccessConditionSet" + }, + "grantControls": { + "$ref": "#/$defs/conditionalAccessGrantControls" + }, + "sessionControls": { + "$ref": "#/$defs/conditionalAccessSessionControls" + } + }, + "$defs": { + "conditionalAccessConditionSet": { + "type": "object", + "title": "Conditions", + "description": "Rules that must be met for the policy to apply.", + "required": ["users", "applications", "clientAppTypes"], + "properties": { + "users": { + "$ref": "#/$defs/conditionalAccessUsers" + }, + "applications": { + "$ref": "#/$defs/conditionalAccessApplications" + }, + "clientAppTypes": { + "type": "array", + "title": "Client App Types", + "description": "Client application types included in the policy.", + "items": { + "type": "string", + "enum": ["all", "browser", "mobileAppsAndDesktopClients", "exchangeActiveSync", "easSupported", "other"] + }, + "enumLabels": { + "all": "All", + "browser": "Browser", + "mobileAppsAndDesktopClients": "Mobile apps and desktop clients", + "exchangeActiveSync": "Exchange ActiveSync", + "easSupported": "EAS supported", + "other": "Other clients" + } + }, + "platforms": { + "$ref": "#/$defs/conditionalAccessPlatforms" + }, + "locations": { + "$ref": "#/$defs/conditionalAccessLocations" + }, + "devices": { + "$ref": "#/$defs/conditionalAccessDevices" + }, + "clientApplications": { + "$ref": "#/$defs/conditionalAccessClientApplications" + }, + "signInRiskLevels": { + "type": "array", + "title": "Sign-in Risk Levels", + "description": "Sign-in risk levels included in the policy. Requires Entra ID P2.", + "items": { + "type": "string", + "enum": ["low", "medium", "high", "hidden", "none"] + }, + "enumLabels": { + "low": "Low", + "medium": "Medium", + "high": "High", + "hidden": "Hidden", + "none": "No risk" + }, + "requiresLicense": "AAD_PREMIUM_P2" + }, + "userRiskLevels": { + "type": "array", + "title": "User Risk Levels", + "description": "User risk levels included in the policy. Requires Entra ID P2.", + "items": { + "type": "string", + "enum": ["low", "medium", "high", "hidden", "none"] + }, + "enumLabels": { + "low": "Low", + "medium": "Medium", + "high": "High", + "hidden": "Hidden", + "none": "No risk" + }, + "requiresLicense": "AAD_PREMIUM_P2" + }, + "servicePrincipalRiskLevels": { + "type": "array", + "title": "Service Principal Risk Levels", + "description": "Service principal risk levels included in the policy.", + "items": { + "type": "string", + "enum": ["low", "medium", "high", "none"] + }, + "enumLabels": { + "low": "Low", + "medium": "Medium", + "high": "High", + "none": "No risk" + } + }, + "insiderRiskLevels": { + "type": "string", + "title": "Insider Risk Levels", + "description": "Insider risk levels included in the policy.", + "enum": ["minor", "moderate", "elevated"], + "enumLabels": { + "minor": "Minor", + "moderate": "Moderate", + "elevated": "Elevated" + } + }, + "authenticationFlows": { + "$ref": "#/$defs/conditionalAccessAuthenticationFlows" + } + } + }, + "conditionalAccessUsers": { + "type": "object", + "title": "Users and Groups", + "description": "Users, groups, and roles included in and excluded from the policy.", + "properties": { + "includeUsers": { + "type": "array", + "title": "Include Users", + "description": "User IDs in scope, or 'All', 'None', 'GuestsOrExternalUsers'.", + "items": { "type": "string" }, + "specialValues": ["All", "None", "GuestsOrExternalUsers"], + "graphLookup": "users" + }, + "excludeUsers": { + "type": "array", + "title": "Exclude Users", + "description": "User IDs excluded from scope.", + "items": { "type": "string" }, + "specialValues": ["GuestsOrExternalUsers"], + "graphLookup": "users" + }, + "includeGroups": { + "type": "array", + "title": "Include Groups", + "description": "Group IDs in scope of the policy.", + "items": { "type": "string" }, + "graphLookup": "groups" + }, + "excludeGroups": { + "type": "array", + "title": "Exclude Groups", + "description": "Group IDs excluded from the policy.", + "items": { "type": "string" }, + "graphLookup": "groups" + }, + "includeRoles": { + "type": "array", + "title": "Include Roles", + "description": "Directory role IDs in scope of the policy.", + "items": { "type": "string" }, + "graphLookup": "directoryRoles" + }, + "excludeRoles": { + "type": "array", + "title": "Exclude Roles", + "description": "Directory role IDs excluded from the policy.", + "items": { "type": "string" }, + "graphLookup": "directoryRoles" + }, + "includeGuestsOrExternalUsers": { + "$ref": "#/$defs/conditionalAccessGuestsOrExternalUsers" + }, + "excludeGuestsOrExternalUsers": { + "$ref": "#/$defs/conditionalAccessGuestsOrExternalUsers" + } + } + }, + "conditionalAccessGuestsOrExternalUsers": { + "type": "object", + "title": "Guests or External Users", + "description": "Internal guests or external user types.", + "properties": { + "guestOrExternalUserTypes": { + "type": "string", + "title": "Guest or External User Types", + "description": "Multi-valued flags. Combine with commas.", + "flagEnum": ["none", "internalGuest", "b2bCollaborationGuest", "b2bCollaborationMember", "b2bDirectConnectUser", "otherExternalUser", "serviceProvider"], + "flagEnumLabels": { + "none": "None", + "internalGuest": "Internal guest", + "b2bCollaborationGuest": "B2B collaboration guest", + "b2bCollaborationMember": "B2B collaboration member", + "b2bDirectConnectUser": "B2B direct connect user", + "otherExternalUser": "Other external user", + "serviceProvider": "Service provider" + } + }, + "externalTenants": { + "$ref": "#/$defs/conditionalAccessExternalTenants" + } + } + }, + "conditionalAccessExternalTenants": { + "type": "object", + "title": "External Tenants", + "description": "External tenant scope.", + "properties": { + "@odata.type": { + "type": "string", + "title": "Membership Kind", + "description": "Whether to enumerate or specify all tenants.", + "enum": [ + "#microsoft.graph.conditionalAccessAllExternalTenants", + "#microsoft.graph.conditionalAccessEnumeratedExternalTenants" + ], + "enumLabels": { + "#microsoft.graph.conditionalAccessAllExternalTenants": "All external tenants", + "#microsoft.graph.conditionalAccessEnumeratedExternalTenants": "Specific tenants" + } + }, + "members": { + "type": "array", + "title": "Tenant IDs", + "description": "List of tenant IDs when using enumerated membership.", + "items": { "type": "string" }, + "visibleWhen": { + "field": "@odata.type", + "value": "#microsoft.graph.conditionalAccessEnumeratedExternalTenants" + } + } + } + }, + "conditionalAccessApplications": { + "type": "object", + "title": "Cloud Apps or Actions", + "description": "Applications and user actions included in and excluded from the policy.", + "properties": { + "includeApplications": { + "type": "array", + "title": "Include Applications", + "description": "Application client IDs the policy applies to, or 'All', 'Office365', 'MicrosoftAdminPortals'.", + "items": { "type": "string" }, + "specialValues": ["All", "Office365", "MicrosoftAdminPortals"], + "specialValueLabels": { + "All": "All cloud apps", + "Office365": "Office 365", + "MicrosoftAdminPortals": "Microsoft Admin Portals" + }, + "graphLookup": "servicePrincipals" + }, + "excludeApplications": { + "type": "array", + "title": "Exclude Applications", + "description": "Application client IDs explicitly excluded.", + "items": { "type": "string" }, + "graphLookup": "servicePrincipals" + }, + "includeUserActions": { + "type": "array", + "title": "User Actions", + "description": "User actions to include instead of cloud apps.", + "items": { + "type": "string", + "enum": ["urn:user:registersecurityinfo", "urn:user:registerdevice"] + }, + "enumLabels": { + "urn:user:registersecurityinfo": "Register security information", + "urn:user:registerdevice": "Register or join devices" + } + }, + "includeAuthenticationContextClassReferences": { + "type": "array", + "title": "Authentication Context", + "description": "Authentication context class references included.", + "items": { "type": "string" } + }, + "applicationFilter": { + "$ref": "#/$defs/conditionalAccessFilter", + "title": "Application Filter", + "description": "Dynamic filter rule for applications." + } + } + }, + "conditionalAccessPlatforms": { + "type": "object", + "title": "Device Platforms", + "description": "Device platforms included in and excluded from the policy.", + "properties": { + "includePlatforms": { + "type": "array", + "title": "Include Platforms", + "description": "Platforms the policy applies to.", + "items": { + "type": "string", + "enum": ["android", "iOS", "windows", "windowsPhone", "macOS", "linux", "all"] + }, + "enumLabels": { + "android": "Android", + "iOS": "iOS", + "windows": "Windows", + "windowsPhone": "Windows Phone", + "macOS": "macOS", + "linux": "Linux", + "all": "All platforms" + } + }, + "excludePlatforms": { + "type": "array", + "title": "Exclude Platforms", + "description": "Platforms excluded from the policy.", + "items": { + "type": "string", + "enum": ["android", "iOS", "windows", "windowsPhone", "macOS", "linux"] + }, + "enumLabels": { + "android": "Android", + "iOS": "iOS", + "windows": "Windows", + "windowsPhone": "Windows Phone", + "macOS": "macOS", + "linux": "Linux" + } + } + } + }, + "conditionalAccessLocations": { + "type": "object", + "title": "Locations", + "description": "Locations included in and excluded from the policy.", + "properties": { + "includeLocations": { + "type": "array", + "title": "Include Locations", + "description": "Named location IDs or 'All', 'AllTrusted'.", + "items": { "type": "string" }, + "specialValues": ["All", "AllTrusted"], + "specialValueLabels": { + "All": "Any location", + "AllTrusted": "All trusted locations" + }, + "graphLookup": "namedLocations" + }, + "excludeLocations": { + "type": "array", + "title": "Exclude Locations", + "description": "Named location IDs excluded.", + "items": { "type": "string" }, + "specialValues": ["AllTrusted"], + "specialValueLabels": { + "AllTrusted": "All trusted locations" + }, + "graphLookup": "namedLocations" + } + } + }, + "conditionalAccessDevices": { + "type": "object", + "title": "Devices", + "description": "Device filter for the policy.", + "properties": { + "deviceFilter": { + "$ref": "#/$defs/conditionalAccessFilter", + "title": "Device Filter", + "description": "Dynamic filter rule for devices using device properties." + } + } + }, + "conditionalAccessFilter": { + "type": "object", + "title": "Filter", + "description": "Dynamic filter with rule syntax.", + "properties": { + "mode": { + "type": "string", + "title": "Filter Mode", + "description": "Whether to include or exclude matching items.", + "enum": ["include", "exclude"], + "enumLabels": { + "include": "Include filtered items", + "exclude": "Exclude filtered items" + } + }, + "rule": { + "type": "string", + "title": "Filter Rule", + "description": "Dynamic membership rule expression. Syntax matches Entra ID dynamic group rules." + } + }, + "required": ["mode", "rule"] + }, + "conditionalAccessClientApplications": { + "type": "object", + "title": "Workload Identities", + "description": "Service principals and workload identities included in and excluded from the policy.", + "properties": { + "includeServicePrincipals": { + "type": "array", + "title": "Include Service Principals", + "description": "Service principal IDs, or 'ServicePrincipalsInMyTenant'.", + "items": { "type": "string" }, + "specialValues": ["ServicePrincipalsInMyTenant"], + "specialValueLabels": { + "ServicePrincipalsInMyTenant": "All service principals" + } + }, + "excludeServicePrincipals": { + "type": "array", + "title": "Exclude Service Principals", + "description": "Service principal IDs excluded.", + "items": { "type": "string" } + }, + "servicePrincipalFilter": { + "$ref": "#/$defs/conditionalAccessFilter", + "title": "Service Principal Filter", + "description": "Dynamic filter rule for service principals." + } + } + }, + "conditionalAccessAuthenticationFlows": { + "type": "object", + "title": "Authentication Flows", + "description": "Authentication flow types in scope.", + "properties": { + "transferMethods": { + "type": "string", + "title": "Transfer Methods", + "description": "Transfer methods in scope for the policy.", + "enum": ["none", "deviceCodeFlow", "authenticationTransfer"], + "enumLabels": { + "none": "None", + "deviceCodeFlow": "Device code flow", + "authenticationTransfer": "Authentication transfer" + } + } + } + }, + "conditionalAccessGrantControls": { + "type": "object", + "title": "Grant Controls", + "description": "Grant controls that must be fulfilled to pass the policy.", + "properties": { + "operator": { + "type": "string", + "title": "Control Operator", + "description": "How multiple controls relate to each other.", + "enum": ["AND", "OR"], + "enumLabels": { + "AND": "Require all selected controls", + "OR": "Require one of the selected controls" + } + }, + "builtInControls": { + "type": "array", + "title": "Built-in Controls", + "description": "Built-in grant controls required by the policy.", + "items": { + "type": "string", + "enum": ["block", "mfa", "compliantDevice", "domainJoinedDevice", "approvedApplication", "compliantApplication", "passwordChange", "riskRemediation"] + }, + "enumLabels": { + "block": "Block access", + "mfa": "Require multifactor authentication", + "compliantDevice": "Require device to be marked as compliant", + "domainJoinedDevice": "Require Microsoft Entra hybrid joined device", + "approvedApplication": "Require approved client app", + "compliantApplication": "Require app protection policy", + "passwordChange": "Require password change", + "riskRemediation": "Require risk remediation" + }, + "constraints": { + "mutuallyExclusive": [["block"], ["mfa", "compliantDevice", "domainJoinedDevice", "approvedApplication", "compliantApplication", "passwordChange", "riskRemediation"]], + "passwordChangeRequires": ["mfa"], + "riskRemediationExcludes": ["passwordChange"] + } + }, + "customAuthenticationFactors": { + "type": "array", + "title": "Custom Controls", + "description": "Custom control IDs required by the policy.", + "items": { "type": "string" } + }, + "termsOfUse": { + "type": "array", + "title": "Terms of Use", + "description": "Terms of use IDs required by the policy.", + "items": { "type": "string" } + }, + "authenticationStrength": { + "$ref": "#/$defs/authenticationStrengthPolicy" + } + } + }, + "authenticationStrengthPolicy": { + "type": "object", + "title": "Authentication Strength", + "description": "Authentication strength policy required. Use instead of or alongside builtInControls.", + "properties": { + "id": { + "type": "string", + "title": "Authentication Strength Policy", + "description": "ID of the authentication strength policy.", + "graphLookup": "authenticationStrengthPolicies", + "wellKnownValues": { + "00000000-0000-0000-0000-000000000002": "Multifactor authentication", + "00000000-0000-0000-0000-000000000003": "Passwordless MFA", + "00000000-0000-0000-0000-000000000004": "Phishing-resistant MFA" + } + } + } + }, + "conditionalAccessSessionControls": { + "type": "object", + "title": "Session Controls", + "description": "Session controls enforced after sign-in.", + "properties": { + "applicationEnforcedRestrictions": { + "$ref": "#/$defs/applicationEnforcedRestrictionsSessionControl" + }, + "cloudAppSecurity": { + "$ref": "#/$defs/cloudAppSecuritySessionControl" + }, + "signInFrequency": { + "$ref": "#/$defs/signInFrequencySessionControl" + }, + "persistentBrowser": { + "$ref": "#/$defs/persistentBrowserSessionControl" + }, + "disableResilienceDefaults": { + "type": "boolean", + "title": "Disable Resilience Defaults", + "description": "When true, Entra ID will not extend existing sessions based on information collected prior to an outage." + } + } + }, + "applicationEnforcedRestrictionsSessionControl": { + "type": "object", + "title": "App Enforced Restrictions", + "description": "Enforce application restrictions. Only Exchange Online and SharePoint Online support this.", + "properties": { + "isEnabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether application enforced restrictions are enabled." + } + } + }, + "cloudAppSecuritySessionControl": { + "type": "object", + "title": "Conditional Access App Control", + "description": "Apply Defender for Cloud Apps controls.", + "properties": { + "isEnabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether cloud app security control is enabled." + }, + "cloudAppSecurityType": { + "type": "string", + "title": "Control Type", + "description": "Type of cloud app security enforcement.", + "enum": ["mcasConfigured", "monitorOnly", "blockDownloads"], + "enumLabels": { + "mcasConfigured": "Use custom policy", + "monitorOnly": "Monitor only", + "blockDownloads": "Block downloads" + } + } + } + }, + "signInFrequencySessionControl": { + "type": "object", + "title": "Sign-in Frequency", + "description": "Enforce periodic reauthentication.", + "properties": { + "isEnabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether sign-in frequency control is enabled." + }, + "value": { + "type": "integer", + "title": "Frequency Value", + "description": "Number of hours or days.", + "minimum": 1 + }, + "type": { + "type": "string", + "title": "Frequency Unit", + "description": "Unit of the sign-in frequency.", + "enum": ["hours", "days"], + "enumLabels": { + "hours": "Hours", + "days": "Days" + } + }, + "frequencyInterval": { + "type": "string", + "title": "Frequency Interval", + "description": "Whether frequency is time-based or every sign-in.", + "enum": ["timeBased", "everyTime"], + "enumLabels": { + "timeBased": "Time-based (use value/type above)", + "everyTime": "Every time" + } + }, + "authenticationType": { + "type": "string", + "title": "Authentication Type", + "description": "Which authentication types this applies to.", + "enum": ["primaryAndSecondaryAuthentication", "secondaryAuthentication"], + "enumLabels": { + "primaryAndSecondaryAuthentication": "Primary and secondary authentication", + "secondaryAuthentication": "Secondary authentication only" + } + } + } + }, + "persistentBrowserSessionControl": { + "type": "object", + "title": "Persistent Browser Session", + "description": "Whether to persist cookies after browser close.", + "properties": { + "isEnabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether persistent browser session control is enabled." + }, + "mode": { + "type": "string", + "title": "Mode", + "description": "Whether browser sessions should always or never persist.", + "enum": ["always", "never"], + "enumLabels": { + "always": "Always persistent", + "never": "Never persistent" + } + } + } + } + } +} diff --git a/src/pages/tenant/conditional/list-policies/edit.jsx b/src/pages/tenant/conditional/list-policies/edit.jsx new file mode 100644 index 000000000000..156cd39f12b2 --- /dev/null +++ b/src/pages/tenant/conditional/list-policies/edit.jsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from "react"; +import { Alert, Box } from "@mui/material"; +import { useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import CippFormSkeleton from "../../../../components/CippFormPages/CippFormSkeleton"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import CippCAPolicyBuilder, { + extractCAPolicyJSON, +} from "../../../../components/CippComponents/CippCAPolicyBuilder"; +import { useSettings } from "../../../../hooks/use-settings.js"; + +const EditCAPolicy = () => { + const router = useRouter(); + const { id: policyId } = router.query; + const tenantFilter = useSettings()?.currentTenant; + const [policyData, setPolicyData] = useState(null); + + const formControl = useForm({ mode: "onChange" }); + + // Fetch the current policies for this tenant + const policiesQuery = ApiGetCall({ + url: `/api/ListConditionalAccessPolicies?tenantFilter=${tenantFilter}`, + queryKey: `CAPolicies-${tenantFilter}`, + enabled: !!policyId && !!tenantFilter, + }); + + useEffect(() => { + if (policiesQuery.isSuccess && policiesQuery.data?.Results) { + const match = policiesQuery.data.Results.find((p) => p.id === policyId); + if (match?.rawjson) { + const parsed = JSON.parse(match.rawjson); + setPolicyData(parsed); + } + } + }, [policiesQuery.isSuccess, policiesQuery.data, policyId]); + + const dataFormatter = (values) => { + const cleaned = extractCAPolicyJSON(values); + return { + tenantFilter, + PolicyId: policyId, + PolicyBody: cleaned, + }; + }; + + return ( + + + {policiesQuery.isLoading ? ( + + ) : policiesQuery.isError ? ( + Error loading policies. + ) : !policyData ? ( + Policy not found for ID: {policyId} + ) : ( + + )} + + + ); +}; + +EditCAPolicy.getLayout = (page) => {page}; + +export default EditCAPolicy; diff --git a/src/pages/tenant/conditional/list-policies/index.js b/src/pages/tenant/conditional/list-policies/index.js index d7f48eac6694..1e85c99b3ebc 100644 --- a/src/pages/tenant/conditional/list-policies/index.js +++ b/src/pages/tenant/conditional/list-policies/index.js @@ -25,6 +25,13 @@ const Page = () => { // Actions configuration const actions = [ + { + label: "Edit Policy", + link: "/tenant/conditional/list-policies/edit?id=[id]", + icon: , + color: "info", + hideBulk: true, + }, { label: "Create template based on policy", type: "POST", diff --git a/src/pages/tenant/conditional/list-template/create.jsx b/src/pages/tenant/conditional/list-template/create.jsx new file mode 100644 index 000000000000..1843c369c323 --- /dev/null +++ b/src/pages/tenant/conditional/list-template/create.jsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Box } from "@mui/material"; +import { useForm } from "react-hook-form"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import CippCAPolicyBuilder, { extractCAPolicyJSON } from "../../../../components/CippComponents/CippCAPolicyBuilder"; + +const CreateCATemplate = () => { + const formControl = useForm({ + mode: "onChange", + defaultValues: { + state: { label: "Report-only", value: "enabledForReportingButNotEnforced" }, + }, + }); + + const customDataFormatter = (values) => { + return extractCAPolicyJSON(values); + }; + + return ( + + + + + + ); +}; + +CreateCATemplate.getLayout = (page) => {page}; + +export default CreateCATemplate; diff --git a/src/pages/tenant/conditional/list-template/edit.jsx b/src/pages/tenant/conditional/list-template/edit.jsx index 9521ddae99ed..6d82be777062 100644 --- a/src/pages/tenant/conditional/list-template/edit.jsx +++ b/src/pages/tenant/conditional/list-template/edit.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Alert, Box, Typography } from "@mui/material"; +import { Alert, Box, Typography, ToggleButtonGroup, ToggleButton } from "@mui/material"; import { useForm } from "react-hook-form"; import { useRouter } from "next/router"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; @@ -7,15 +7,29 @@ import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; import CippFormSkeleton from "../../../../components/CippFormPages/CippFormSkeleton"; import { ApiGetCall } from "../../../../api/ApiCall"; import CippTemplateFieldRenderer from "../../../../components/CippComponents/CippTemplateFieldRenderer"; +import CippCAPolicyBuilder, { + extractCAPolicyJSON, +} from "../../../../components/CippComponents/CippCAPolicyBuilder"; const EditCATemplate = () => { const router = useRouter(); const { GUID } = router.query; const [templateData, setTemplateData] = useState(null); const [originalData, setOriginalData] = useState(null); + const [editorMode, setEditorMode] = useState("builder"); const formControl = useForm({ mode: "onChange" }); + // When switching to builder mode, reset the form to clear any empty [] + // values that CippTemplateFieldRenderer may have injected + const handleEditorModeChange = (e, val) => { + if (!val) return; + if (val === "builder" && editorMode !== "builder") { + formControl.reset({}); + } + setEditorMode(val); + }; + // Fetch the template data const templateQuery = ApiGetCall({ url: `/api/ListCATemplates?GUID=${GUID}`, @@ -110,6 +124,14 @@ const EditCATemplate = () => { }; }; + // Build a data formatter that works for both editor modes + const builderDataFormatter = (values) => { + const cleaned = extractCAPolicyJSON(values); + return { GUID, ...cleaned }; + }; + + const activeFormatter = editorMode === "builder" ? builderDataFormatter : customDataFormatter; + return ( { queryKey={[`CATemplate-${GUID}`, "CATemplates"]} backButtonTitle="Conditional Access Templates" postUrl="/api/ExecEditTemplate?type=CATemplate" - customDataformatter={customDataFormatter} + customDataformatter={activeFormatter} formPageType="Edit" + titleButton={ + + Policy Builder + Field Editor + + } > {templateQuery.isLoading ? ( ) : templateQuery.isError || !templateData ? ( Error loading template or template not found. + ) : editorMode === "builder" ? ( + ) : ( { const pageTitle = "Available Conditional Access Templates"; @@ -144,7 +145,13 @@ const Page = () => { simpleColumns={["displayName", "package", "GUID"]} cardButton={ - Date: Wed, 13 May 2026 18:54:03 +0800 Subject: [PATCH 049/164] Update CippCAPolicyBuilder.jsx --- .../CippComponents/CippCAPolicyBuilder.jsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/CippComponents/CippCAPolicyBuilder.jsx b/src/components/CippComponents/CippCAPolicyBuilder.jsx index c7999edff6c6..30a38673bd4e 100644 --- a/src/components/CippComponents/CippCAPolicyBuilder.jsx +++ b/src/components/CippComponents/CippCAPolicyBuilder.jsx @@ -1117,5 +1117,25 @@ export function extractCAPolicyJSON(formValues) { ); } + // Post-process: strip session control sub-objects where isEnabled is false. + // Graph validates fields like `mode` even when disabled — safest to omit entirely. + if (cleaned.sessionControls) { + const sessionKeys = [ + "applicationEnforcedRestrictions", + "cloudAppSecurity", + "signInFrequency", + "persistentBrowser", + ]; + for (const key of sessionKeys) { + if (cleaned.sessionControls[key]?.isEnabled === false) { + delete cleaned.sessionControls[key]; + } + } + // If sessionControls is now empty, remove it too + if (Object.keys(cleaned.sessionControls).length === 0) { + delete cleaned.sessionControls; + } + } + return cleaned; } From 60a50738fc68fd8b17ea615343f785828c713209 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 13 May 2026 21:08:55 +0200 Subject: [PATCH 050/164] fixes tenantfilter property --- .../administration/mailbox-rules/index.js | 113 ++++++++++-------- 1 file changed, 60 insertions(+), 53 deletions(-) diff --git a/src/pages/email/administration/mailbox-rules/index.js b/src/pages/email/administration/mailbox-rules/index.js index 4b9a9ece88cb..98f0d076caff 100644 --- a/src/pages/email/administration/mailbox-rules/index.js +++ b/src/pages/email/administration/mailbox-rules/index.js @@ -1,90 +1,97 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { getCippTranslation } from "../../../../utils/get-cipp-translation"; -import { getCippFormatting } from "../../../../utils/get-cipp-formatting"; -import { CippPropertyListCard } from "../../../../components/CippCards/CippPropertyListCard"; -import { Block, PlayArrow, DeleteForever } from "@mui/icons-material"; -import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { getCippTranslation } from '../../../../utils/get-cipp-translation' +import { getCippFormatting } from '../../../../utils/get-cipp-formatting' +import { CippPropertyListCard } from '../../../../components/CippCards/CippPropertyListCard' +import { Block, PlayArrow, DeleteForever } from '@mui/icons-material' +import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' const Page = () => { - const pageTitle = "Mailbox Rules"; + const pageTitle = 'Mailbox Rules' const reportDB = useCippReportDB({ - apiUrl: "/api/ListMailboxRules", - queryKey: "ListMailboxRules", - cacheName: "Mailboxes", - syncTitle: "Sync Mailbox Rules", - syncData: { Types: "Rules" }, + apiUrl: '/api/ListMailboxRules', + queryKey: 'ListMailboxRules', + cacheName: 'Mailboxes', + syncTitle: 'Sync Mailbox Rules', + syncData: { Types: 'Rules' }, allowToggle: false, defaultCached: true, - }); + }) const simpleColumns = [ - ...reportDB.cacheColumns.filter((c) => c === "Tenant"), - "UserPrincipalName", - "Name", - "Priority", - "Enabled", - "From", - ...reportDB.cacheColumns.filter((c) => c !== "Tenant"), - ]; + ...reportDB.cacheColumns.filter((c) => c === 'Tenant'), + 'UserPrincipalName', + 'Name', + 'Priority', + 'Enabled', + 'From', + ...reportDB.cacheColumns.filter((c) => c !== 'Tenant'), + ] const actions = [ { - label: "Enable Mailbox Rule", - type: "POST", + label: 'Enable Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecSetMailboxRule", + url: '/api/ExecSetMailboxRule', data: { - ruleId: "Identity", - userPrincipalName: "OperationGuid", - ruleName: "Name", + ruleId: 'Identity', + userPrincipalName: 'OperationGuid', + ruleName: 'Name', Enable: true, + tenantFilter: 'Tenant', }, condition: (row) => !row.Enabled, - confirmText: "Are you sure you want to enable this mailbox rule?", + confirmText: 'Are you sure you want to enable this mailbox rule?', multiPost: false, }, { - label: "Disable Mailbox Rule", - type: "POST", + label: 'Disable Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecSetMailboxRule", + url: '/api/ExecSetMailboxRule', data: { - ruleId: "Identity", - userPrincipalName: "OperationGuid", - ruleName: "Name", + ruleId: 'Identity', + userPrincipalName: 'OperationGuid', + ruleName: 'Name', Disable: true, + tenantFilter: 'Tenant', }, condition: (row) => row.Enabled, - confirmText: "Are you sure you want to disable this mailbox rule?", + confirmText: 'Are you sure you want to disable this mailbox rule?', multiPost: false, }, { - label: "Remove Mailbox Rule", - type: "POST", + label: 'Remove Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecRemoveMailboxRule", - data: { ruleId: "Identity", userPrincipalName: "OperationGuid", ruleName: "Name" }, - confirmText: "Are you sure you want to remove this mailbox rule?", + url: '/api/ExecRemoveMailboxRule', + data: { + ruleId: 'Identity', + userPrincipalName: 'OperationGuid', + ruleName: 'Name', + tenantFilter: 'Tenant', + }, + confirmText: 'Are you sure you want to remove this mailbox rule?', multiPost: false, }, - ]; + ] const offCanvas = { children: (data) => { const keys = Object.keys(data).filter( - (key) => !key.includes("@odata") && !key.includes("@data"), - ); - const properties = []; + (key) => !key.includes('@odata') && !key.includes('@data') + ) + const properties = [] keys.forEach((key) => { if (data[key] && data[key].length > 0) { properties.push({ label: getCippTranslation(key), value: getCippFormatting(data[key], key), - }); + }) } - }); + }) return ( { actionItems={actions} data={data} /> - ); + ) }, - }; + } return ( <> @@ -111,8 +118,8 @@ const Page = () => { /> {reportDB.syncDialog} - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page From c958401045bf32a2326446cb24b4550e63b15f75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 23:43:59 +0000 Subject: [PATCH 051/164] chore(deps): bump dompurify from 3.4.2 to 3.4.3 Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.2 to 3.4.3. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.2...3.4.3) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 15 ++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index a4402ea681b5..76e6c08f07d0 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "axios": "1.15.0", "date-fns": "4.1.0", "diff": "^8.0.3", - "dompurify": "^3.4.2", + "dompurify": "^3.4.3", "eml-parse-js": "^1.2.0-beta.0", "export-to-csv": "^1.3.0", "formik": "2.4.9", diff --git a/yarn.lock b/yarn.lock index acfda103a76e..56f92541f6b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3717,17 +3717,10 @@ dompurify@3.2.7: optionalDependencies: "@types/trusted-types" "^2.0.7" -dompurify@^3.3.1: - version "3.3.3" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.3.3.tgz#680cae8af3e61320ddf3666a3bc843f7b291b2b6" - integrity sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA== - optionalDependencies: - "@types/trusted-types" "^2.0.7" - -dompurify@^3.4.2: - version "3.4.2" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.2.tgz#f0ff81be682c485505097ba8195a058d8f575218" - integrity sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA== +dompurify@^3.3.1, dompurify@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.3.tgz#3ef336e7a757c3bf1efbd3781afb149a3ae5cfa4" + integrity sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A== optionalDependencies: "@types/trusted-types" "^2.0.7" From a783d28ab623dc69767ffd758e5cd140f171f2d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 23:44:16 +0000 Subject: [PATCH 052/164] chore(deps): bump @tiptap/extension-table from 3.20.4 to 3.20.5 Bumps [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) from 3.20.4 to 3.20.5. - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-table/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.20.5/packages/extension-table) --- updated-dependencies: - dependency-name: "@tiptap/extension-table" dependency-version: 3.20.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a4402ea681b5..ac434fa694a5 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@tanstack/react-table": "^8.19.2", "@tiptap/core": "^3.4.1", "@tiptap/extension-heading": "^3.4.1", - "@tiptap/extension-table": "^3.19.0", + "@tiptap/extension-table": "^3.20.5", "@tiptap/pm": "^3.22.3", "@tiptap/react": "^3.20.5", "@tiptap/starter-kit": "^3.20.5", diff --git a/yarn.lock b/yarn.lock index acfda103a76e..fd4f55d4b40e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2259,10 +2259,10 @@ resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.20.5.tgz#a3689fc17ad89a23c88f11b27c7f53896caa54f3" integrity sha512-uwhvmfS4ciGYJRLUg0AHbWsprMCwyWVWd2RXOLRm0ZQeWkvzonPXZhJvzIhIgsFkPLj/dsN5t0+LdiK4UQMnyA== -"@tiptap/extension-table@^3.19.0": - version "3.20.4" - resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.20.4.tgz#b2067cf1609bb1c39b61e504dc4aa05cba13d9ca" - integrity sha512-vEHXRL9k9G02pp3P+DyUnN4YRaRAHGfTBC6gck0s9TpsCM9NIchL0qI1fb/u46Bu6UaoMMk58DGr7xaJ29g7KQ== +"@tiptap/extension-table@^3.20.5": + version "3.20.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.20.5.tgz#bac3d76e1c5fc8a4672f1495532a934651f50ce8" + integrity sha512-YvTB5OfGqjqHqutkSyywplouFvJwlsDTpZAjtAh5TzKfOan42aiVepmHVpteoQP6LH0mSjw69RndFMIYhIGmSQ== "@tiptap/extension-text@^3.20.5": version "3.20.5" From 2285d39cfdfdbbdffe377fbcc357200763f27e84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 23:44:34 +0000 Subject: [PATCH 053/164] chore(deps): bump @tiptap/core from 3.20.5 to 3.22.3 Bumps [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) from 3.20.5 to 3.22.3. - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/core/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.3/packages/core) --- updated-dependencies: - dependency-name: "@tiptap/core" dependency-version: 3.22.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a4402ea681b5..cfb4488fb562 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@tanstack/react-query-devtools": "^5.96.2", "@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-table": "^8.19.2", - "@tiptap/core": "^3.4.1", + "@tiptap/core": "^3.22.3", "@tiptap/extension-heading": "^3.4.1", "@tiptap/extension-table": "^3.19.0", "@tiptap/pm": "^3.22.3", diff --git a/yarn.lock b/yarn.lock index acfda103a76e..2a1e1d68ef0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2145,10 +2145,10 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212" integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw== -"@tiptap/core@^3.20.5", "@tiptap/core@^3.4.1": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.20.5.tgz#edf98b45f98463b12ed59357ea9b4bf155e3e194" - integrity sha512-Pkjd41UJ4F6Z8cPV+gEvqnt1VhY2g66xMjbpxREs0ECA5jRezCNKSZcc2pueQRTMtmn1SaSzGM9U/ifhVlVYOA== +"@tiptap/core@^3.20.5", "@tiptap/core@^3.22.3": + version "3.22.3" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.22.3.tgz#89cd6d3d374f5f757bcb5e18e70c346a9eb9b2cd" + integrity sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q== "@tiptap/extension-blockquote@^3.20.5": version "3.20.5" From d392d1cfebf3dd1fc82e50e0cc0fda449814d693 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 23:45:13 +0000 Subject: [PATCH 054/164] chore(deps): bump @tanstack/react-query from 5.96.2 to 5.100.10 Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.2 to 5.100.10. - [Release notes](https://github.com/TanStack/query/releases) - [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md) - [Commits](https://github.com/TanStack/query/commits/HEAD/packages/react-query) --- updated-dependencies: - dependency-name: "@tanstack/react-query" dependency-version: 5.100.10 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a4402ea681b5..3b319eaa4bcc 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@react-pdf/renderer": "^4.3.2", "@reduxjs/toolkit": "^2.11.2", "@tanstack/query-sync-storage-persister": "^5.90.25", - "@tanstack/react-query": "^5.96.2", + "@tanstack/react-query": "^5.100.10", "@tanstack/react-query-devtools": "^5.96.2", "@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-table": "^8.19.2", diff --git a/yarn.lock b/yarn.lock index acfda103a76e..856baa2a6b63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2051,6 +2051,11 @@ dependencies: remove-accents "0.5.0" +"@tanstack/query-core@5.100.10": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.100.10.tgz#aeb34d301fd4ff9762e67dfa018adc33b7a18be4" + integrity sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w== + "@tanstack/query-core@5.91.2": version "5.91.2" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.91.2.tgz#d83825a928aa49ded38d3910f05284178cce89d3" @@ -2102,12 +2107,12 @@ dependencies: "@tanstack/query-persist-client-core" "5.96.2" -"@tanstack/react-query@^5.96.2": - version "5.96.2" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.96.2.tgz#a164abfb80eb5e7772bbcddfa7240f3fd8d0d7be" - integrity sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA== +"@tanstack/react-query@^5.100.10": + version "5.100.10" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.100.10.tgz#3bf1844efd76f5f68f9f39da2917fc4c6023e726" + integrity sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q== dependencies: - "@tanstack/query-core" "5.96.2" + "@tanstack/query-core" "5.100.10" "@tanstack/react-table@8.20.6": version "8.20.6" From fd6a9e36007839cbb51f850ac75b5585726724a0 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 14 May 2026 15:38:27 +1000 Subject: [PATCH 055/164] Logs --- src/layouts/config.js | 7 + src/pages/cipp/advanced/container-logs.js | 388 ++++++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 src/pages/cipp/advanced/container-logs.js diff --git a/src/layouts/config.js b/src/layouts/config.js index ad0004307a96..a9df0957241d 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -1109,6 +1109,13 @@ export const nativeMenuItems = [ permissions: ['CIPP.SuperAdmin.*'], scope: 'global', }, + { + title: 'Container Logs', + path: '/cipp/advanced/container-logs', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, ], }, ], diff --git a/src/pages/cipp/advanced/container-logs.js b/src/pages/cipp/advanced/container-logs.js new file mode 100644 index 000000000000..c014d71734d8 --- /dev/null +++ b/src/pages/cipp/advanced/container-logs.js @@ -0,0 +1,388 @@ +import { useState, useEffect, useMemo } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { + Box, + Button, + Stack, + Typography, + Chip, + Accordion, + AccordionSummary, + AccordionDetails, + Alert, +} from "@mui/material"; +import { ExpandMore, Search, Refresh } from "@mui/icons-material"; +import { CippFormComponent } from "../../../components/CippComponents/CippFormComponent"; +import { Grid } from "@mui/system"; +import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { CippTablePage } from "../../../components/CippComponents/CippTablePage"; +import { ApiGetCall } from "../../../api/ApiCall"; + +const levelOptions = [ + { label: "All Levels", value: "" }, + { label: "Debug", value: "DBG" }, + { label: "Information", value: "INF" }, + { label: "Warning", value: "WRN" }, + { label: "Error", value: "ERR" }, + { label: "Critical", value: "CRT" }, +]; + +const timeRangeOptions = [ + { label: "Last 15 minutes", value: "15" }, + { label: "Last 30 minutes", value: "30" }, + { label: "Last 1 hour", value: "60" }, + { label: "Last 3 hours", value: "180" }, + { label: "Last 6 hours", value: "360" }, + { label: "Last 12 hours", value: "720" }, + { label: "Last 24 hours", value: "1440" }, + { label: "Custom Range", value: "custom" }, + { label: "No Time Filter", value: "" }, +]; + +const getLevelColor = (level) => { + switch (level) { + case "CRT": + return "error"; + case "ERR": + return "error"; + case "WRN": + return "warning"; + case "INF": + return "info"; + case "DBG": + return "default"; + default: + return "default"; + } +}; + +const getLevelLabel = (level) => { + switch (level) { + case "CRT": + return "Critical"; + case "ERR": + return "Error"; + case "WRN": + return "Warning"; + case "INF": + return "Info"; + case "DBG": + return "Debug"; + case "TRC": + return "Trace"; + default: + return level || "Unknown"; + } +}; + +const ContainerLogsFilter = ({ onSubmitFilter }) => { + const [expanded, setExpanded] = useState(true); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + timeRange: "60", + level: "", + search: "", + file: "", + tail: "500", + searchAll: false, + fromDate: "", + toDate: "", + }, + }); + + const { handleSubmit } = formControl; + const timeRange = useWatch({ control: formControl.control, name: "timeRange" }); + + const fileListQuery = ApiGetCall({ + url: "/api/ListContainerLogs", + data: { Action: "ListFiles" }, + queryKey: "ContainerLogFiles", + }); + + const fileOptions = useMemo(() => { + const opts = [{ label: "Current Log", value: "" }]; + if (fileListQuery.isSuccess && fileListQuery.data?.Results) { + fileListQuery.data.Results.forEach((f) => { + if (!f.IsCurrent) { + opts.push({ + label: `${f.Name} (${f.SizeFormatted})`, + value: f.Name, + }); + } + }); + } + return opts; + }, [fileListQuery.isSuccess, fileListQuery.data]); + + const onSubmit = (values) => { + const params = { + Action: values.searchAll ? "SearchAll" : "ReadLog", + Tail: values.tail || "500", + }; + + // Level filter + const levelVal = Array.isArray(values.level) ? values.level[0]?.value : values.level; + if (levelVal) params.Level = levelVal; + + // Search text + if (values.search) params.Search = values.search; + + // File selection + const fileVal = Array.isArray(values.file) ? values.file[0]?.value : values.file; + if (fileVal && !values.searchAll) params.File = fileVal; + + // Time range + const rangeVal = Array.isArray(values.timeRange) + ? values.timeRange[0]?.value + : values.timeRange; + if (rangeVal === "custom") { + if (values.fromDate) params.From = new Date(values.fromDate).toISOString(); + if (values.toDate) params.To = new Date(values.toDate).toISOString(); + } else if (rangeVal && rangeVal !== "") { + const minutes = parseInt(rangeVal, 10); + if (!isNaN(minutes)) { + params.From = new Date(Date.now() - minutes * 60 * 1000).toISOString(); + } + } + + onSubmitFilter(params); + setExpanded(false); + }; + + const handleClear = () => { + formControl.reset(); + onSubmitFilter(null); + setExpanded(true); + }; + + return ( + setExpanded(!expanded)}> + }> + Log Filters + + + + + Search the local container log files directly. Logs are rotated by size and retained on + disk. Use “Search All Files” to search across rotated log files. + + + + + + + + + + + + + + + + + + + + {(Array.isArray(timeRange) ? timeRange[0]?.value : timeRange) === "custom" && ( + + + + + + + + + )} + + + + + + + + + + + + + + + + + + + + ); +}; + +const Page = () => { + const [apiFilter, setApiFilter] = useState(null); + const queryKey = JSON.stringify(apiFilter); + + return ( + + + + + + } + clearOnError={true} + offCanvas={{ + size: "lg", + children: (row) => { + const levelColor = getLevelColor(row.Level); + return ( + + + + + + + {row.Timestamp} + + + + + + Message + + + + {row.Message} + + + + {row.Raw && row.Raw !== row.Message && ( + + + Raw Log Line + + + + {row.Raw} + + + + )} + + + ); + }, + }} + title="Container Logs" + tenantInTitle={false} + apiDataKey="Results" + apiUrl={apiFilter ? "/api/ListContainerLogs" : "/api/ListEmptyResults"} + apiData={apiFilter} + queryKey={queryKey} + simpleColumns={["Timestamp", "Level", "Message"]} + actions={[]} + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; From b5d48bcddaed40af6839e15ba7a9907c4b22ad33 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 14 May 2026 18:09:04 +1000 Subject: [PATCH 056/164] logging --- src/data/ContainerLogPresets.json | 52 +++ src/pages/cipp/advanced/container-logs.js | 392 ++++++++++++++++------ 2 files changed, 345 insertions(+), 99 deletions(-) create mode 100644 src/data/ContainerLogPresets.json diff --git a/src/data/ContainerLogPresets.json b/src/data/ContainerLogPresets.json new file mode 100644 index 000000000000..71f3dc5b8e93 --- /dev/null +++ b/src/data/ContainerLogPresets.json @@ -0,0 +1,52 @@ +[ + { + "name": "Recent Errors (Last 1h)", + "id": "cl-preset-errors-1h", + "query": "where Level in (\"ERR\", \"CRT\")\n| where Timestamp > ago(1h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Warnings & Errors (Last 24h)", + "id": "cl-preset-warn-err-24h", + "query": "where Level in (\"ERR\", \"CRT\", \"WRN\")\n| where Timestamp > ago(24h)\n| take 1000\n| sort by Timestamp desc" + }, + { + "name": "All Logs (Last 15 min)", + "id": "cl-preset-all-15m", + "query": "where Timestamp > ago(15m)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Startup Logs", + "id": "cl-preset-startup", + "query": "where Message contains \"Starting\"\n| where Message !contains \"heartbeat\"\n| take 200\n| sort by Timestamp desc" + }, + { + "name": "Graph API Errors", + "id": "cl-preset-graph-errors", + "query": "where Level in (\"ERR\", \"CRT\")\n| where Message matches regex \"graph|Graph|GRAPH\"\n| where Timestamp > ago(24h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Token / Auth Issues", + "id": "cl-preset-auth", + "query": "where Message matches regex \"token|auth|unauthorized|forbidden|401|403\"\n| where Timestamp > ago(24h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Timeout Errors", + "id": "cl-preset-timeouts", + "query": "where Message matches regex \"timeout|timed out|TaskCanceled\"\n| where Timestamp > ago(24h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "All Errors (Search All Files)", + "id": "cl-preset-all-errors", + "query": "search all files\n| where Level in (\"ERR\", \"CRT\")\n| take 1000\n| sort by Timestamp desc" + }, + { + "name": "Standards Processing", + "id": "cl-preset-standards", + "query": "where Message contains \"Standard\"\n| where Timestamp > ago(24h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Full Log (Last 1h, no heartbeats)", + "id": "cl-preset-full-clean", + "query": "where Timestamp > ago(1h)\n| where Message !contains \"heartbeat\"\n| take 1000\n| sort by Timestamp desc" + } +] diff --git a/src/pages/cipp/advanced/container-logs.js b/src/pages/cipp/advanced/container-logs.js index c014d71734d8..b77aac6590a9 100644 --- a/src/pages/cipp/advanced/container-logs.js +++ b/src/pages/cipp/advanced/container-logs.js @@ -10,13 +10,17 @@ import { AccordionSummary, AccordionDetails, Alert, + AlertTitle, + Tab, + Tabs, } from "@mui/material"; -import { ExpandMore, Search, Refresh } from "@mui/icons-material"; +import { ExpandMore, Search, Refresh, PlayArrow } from "@mui/icons-material"; import { CippFormComponent } from "../../../components/CippComponents/CippFormComponent"; import { Grid } from "@mui/system"; import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { CippTablePage } from "../../../components/CippComponents/CippTablePage"; import { ApiGetCall } from "../../../api/ApiCall"; +import defaultPresets from "../../../data/ContainerLogPresets.json"; const levelOptions = [ { label: "All Levels", value: "" }, @@ -77,23 +81,64 @@ const getLevelLabel = (level) => { const ContainerLogsFilter = ({ onSubmitFilter }) => { const [expanded, setExpanded] = useState(true); + const [tabValue, setTabValue] = useState(0); // 0 = Query, 1 = Guided + const [selectedPreset, setSelectedPreset] = useState(null); - const formControl = useForm({ + // Query mode form + const queryForm = useForm({ + mode: "onChange", + defaultValues: { + queryPreset: null, + query: 'where Timestamp > ago(1h)\n| take 500\n| sort by Timestamp desc', + }, + }); + + const queryValue = useWatch({ control: queryForm.control, name: "query" }); + const queryPreset = useWatch({ control: queryForm.control, name: "queryPreset" }); + + // Guided mode form + const guidedForm = useForm({ mode: "onChange", defaultValues: { timeRange: "60", level: "", search: "", + exclude: "", + regex: "", file: "", tail: "500", searchAll: false, + sortDesc: true, fromDate: "", toDate: "", }, }); - const { handleSubmit } = formControl; - const timeRange = useWatch({ control: formControl.control, name: "timeRange" }); + const timeRange = useWatch({ control: guidedForm.control, name: "timeRange" }); + + // Preset options (built-in only — no API save/load for container log presets) + const presetOptions = useMemo( + () => + defaultPresets.map((preset) => ({ + label: preset.name, + value: preset.id, + query: preset.query, + isBuiltin: true, + })), + [] + ); + + // Load preset when selected + useEffect(() => { + if (queryPreset) { + const preset = Array.isArray(queryPreset) ? queryPreset[0] : queryPreset; + if (preset?.query) { + queryForm.setValue("query", preset.query); + setSelectedPreset(preset); + queryForm.setValue("queryPreset", null); + } + } + }, [queryPreset, queryForm]); const fileListQuery = ApiGetCall({ url: "/api/ListContainerLogs", @@ -116,12 +161,26 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { return opts; }, [fileListQuery.isSuccess, fileListQuery.data]); - const onSubmit = (values) => { + // Submit query mode + const handleQuerySubmit = queryForm.handleSubmit((values) => { + if (values.query && values.query.trim()) { + onSubmitFilter({ + Action: "Query", + Query: values.query.trim(), + }); + setExpanded(false); + } + }); + + // Submit guided mode + const handleGuidedSubmit = guidedForm.handleSubmit((values) => { const params = { Action: values.searchAll ? "SearchAll" : "ReadLog", Tail: values.tail || "500", }; + if (values.sortDesc) params.SortDesc = "true"; + // Level filter const levelVal = Array.isArray(values.level) ? values.level[0]?.value : values.level; if (levelVal) params.Level = levelVal; @@ -129,6 +188,12 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { // Search text if (values.search) params.Search = values.search; + // Exclude text + if (values.exclude) params.Exclude = values.exclude; + + // Regex pattern + if (values.regex) params.Regex = values.regex; + // File selection const fileVal = Array.isArray(values.file) ? values.file[0]?.value : values.file; if (fileVal && !values.searchAll) params.File = fileVal; @@ -149,10 +214,15 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { onSubmitFilter(params); setExpanded(false); - }; + }); const handleClear = () => { - formControl.reset(); + if (tabValue === 0) { + queryForm.reset(); + setSelectedPreset(null); + } else { + guidedForm.reset(); + } onSubmitFilter(null); setExpanded(true); }; @@ -160,115 +230,239 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { return ( setExpanded(!expanded)}> }> - Log Filters + Log Query - - - Search the local container log files directly. Logs are rotated by size and retained on - disk. Use “Search All Files” to search across rotated log files. - - - - - - - - - - - - - - - - + + setTabValue(v)} + sx={{ borderBottom: 1, borderColor: "divider" }} + > + + + + + {/* ── Tab 0: Query Editor ── */} + {tabValue === 0 && ( + + + + Query Syntax + + Use a KQL-inspired pipe syntax to filter container logs. Separate clauses with{" "} + |. Supported operators: + + + where Level == "ERR" — exact level +
+ where Level in ("ERR", "CRT") — multiple + levels +
+ where Level != "DBG" — exclude level +
+ where Message contains "text" — search +
+ where Message !contains "text" — exclude +
+ where Message matches regex "err|fail" — regex +
+ where Timestamp > ago(1h) — relative time (s/m/h/d/w) +
+ where Timestamp between (ago(2h) .. ago(1h)) — range +
+ take 500 — limit results +
+ sort by Timestamp desc — newest first +
+ search all files — include rotated logs +
+
+ + + + + -
- {(Array.isArray(timeRange) ? timeRange[0]?.value : timeRange) === "custom" && ( + ago(1h)\n| take 500\n| sort by Timestamp desc`} + sx={{ + "& textarea": { + fontFamily: "monospace", + fontSize: "0.875rem", + }, + }} + /> + + + + + +
+
+ )} + + {/* ── Tab 1: Guided Filter ── */} + {tabValue === 1 && ( + + + + Search the local container log files directly. Logs are rotated by size and + retained on disk. Use “Search All Files” to search across rotated log + files. + + - + + + + + + + - + - )} - - - - + + + + + + + + + + + - - + + {(Array.isArray(timeRange) ? timeRange[0]?.value : timeRange) === "custom" && ( + + + + + + + + + )} + + + + + + + + - - - - - + + + + + - - +
+ )} From 12a07270752ea03f3c94eb33f419fdd7336f5985 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 14 May 2026 14:34:30 +0200 Subject: [PATCH 057/164] comingsoon --- public/assets/integrations/autotask.png | Bin 0 -> 7685 bytes public/assets/integrations/connectwise.png | Bin 0 -> 18445 bytes public/assets/integrations/kaseya.svg | 3 + src/data/Extensions.json | 30 +++++ src/pages/cipp/integrations/index.js | 132 +++++++++++++++------ 5 files changed, 128 insertions(+), 37 deletions(-) create mode 100644 public/assets/integrations/autotask.png create mode 100644 public/assets/integrations/connectwise.png create mode 100644 public/assets/integrations/kaseya.svg diff --git a/public/assets/integrations/autotask.png b/public/assets/integrations/autotask.png new file mode 100644 index 0000000000000000000000000000000000000000..cf29404276136ca8860f3c62fb3bf6a60ebe5784 GIT binary patch literal 7685 zcmc(E1x#FBw>IuAZpDhrAT1P%lu{_}K7$YL?p_9W*J3Sh!{9DcpcF4&Y^D^4;;y~C z_f5XM`EK(4H-G-*-`P1?``OQW*4isO`|Ohwt*$Ef1n(Ih3JS^-1$k*r6cjWN3JR(i z4*DY^^nx(*5eQaS)|QcTQAUvt_z}j#%?`!BD*1^-R+`60&Y%cpuN9?hnwdVU$IY_Md$w7RISH|4cNGRe6k796=@Qz^jic9Uwp>uKc(o zyF4^iwNOyq?{5VT(vMG{UtgF#=u^lLVqs8iPAZ@xrDxGTpvU38j<&+g!>}JMIec>2 zUARm3%Hzi5;rCrXhe!~%6E05M`b`Bdz6MK2EE6%TVS1A;@EhEEO*OSBiH)1)Y{G($ z^UdN%xg+nLGSa7Z(0AcK{3#Gu&8{bJC4~rH6zM{A<@Bmn5=gTA;%(1mNx2%Azh-JF zMZ$XXMPKwAtkUmiGOf;;O}Rqa7beVyD-G1W?%qY+?MkIg*8ZG9v)EohL3!q*AT6ow zv-o@Y+Jw}a9{Z?xH7X2DprFoJ_)a?^>^(67i;s(ZYxQ#yjJkFXGV=1YJ_^hkzvnt+ zj`o->OGX_~@yp+Yx5_JChim7HdxVRIN%r|e%2wY-<}XekhB7TSeY39D#U=y<2>GcW z>-PVp^2&S(CVuVwAshqR0Q z?%U1m+TbvBRL#YQ?J4Tb>r5)+kYe+T)I(Y`r{FL*%qdk&Wb4-#YJ5e8A^lqL6%HT_ zOG`51`lXX`$jqbBb`Mve#Y=MGy2`NU`u0NN7I&Cj{tdZB)@#1NsL0Ug$J*bGpop(< z6x=e%gm=ft>=p+Q`5JrcwR%Ypn;X|vxc{Tq{CM6#NJdn;TlbW|F*IoLWNfcIMXaG; z1=Cr814z=5J{kl~v*ZIxlo5pWeUYsDD}gK0GypEErfRy>z1#UI8xR_0Cf8VZqKZjv zHNVo*p8;rlGlwlhf6Mj<2@&*36aW=V0<0cmMZWa~I#AlPn@c3LMS-ImkA=NqY(ml! z?H!92QA^pOjoJdRz zI7x*oqb4luDe^Ku=&<>j!v(NBI6#&g;o5-MpQiS zySpi)?7vX$m=>^%w}kC(W~`_%ftU!*@uO`_82`My9oGJ?14aCa-<~B|0qU@Pg^O;! zBE?YZ96tF5*EA;ktGEm?e86S^bvJ!KH>l#=wwsNZ+qD#z&`8$!)^GkEQeJB){+6fR zuuX3N<u{O)U`ERAd(r)vrov~$C_5;I z8Lboobvw_#ax0-jGJFro!l*jP!b@eDuuS>B%l$;%G6(_lz?ARa9v8_PSkMXo zCWR67c^t{#1LlnMQN>)@P`sPEp2?p3c`^SMR9eIsiiV*?;E&e>PUaPDP)2Uw@m2 zvgH%IU-~0v52eezvGWX^#%FO&tqwe*W@MvajQ^@zFIEI?JyWU9`bxFjB4g&6|3nJVO1QWseCnkFA4y zD*#~=Qpx0iS1ep;I4)M8B{WOMXn6AFfOQaL)1#oce4Nl(l^r{{+mA6ary{luMRIn# zjJa7>5hnpslBEmq+J|RLQgaVahs4rxtFb67>-mvABcxm$@$8wkPK6e}I*W6T&c?cv zeopjVqSh);5KLX>B}2lCx({slUJ)%maz<&g`v)!sG-dv3AqpnD#XLKlp|$ z*u&03!}2ytZ|0v6JALF3&6Nv9zz|v7#Y2@z6oNpz3xJQ)$7P|1FhPB{EwqDkV8fcW5H4Ul#c|vL-_EvSnfhmoOv1>O)Z)qV?cGxrNoz z!>3RCLbE~7cbZKHnzOh-6PWc)JitT`(7LDz6BPQ$^|;-! z{eMNhbLDCJgx+|Dh3c=sTaWj2PBtNuiz~7$;1cu=)KZq?w2}LqFlXOi0n~)^2LzJ{JsuW`p_$|M-f``3sjI*` z`ebNoNBIgx<>2#mU{ZM;1_DamSxeI1Wje&c0y^%Z4Mjuh9R9GHC0|G;d*6pM1hb?0 z*7yppXqI^Pu31?~b?Aer1=ltg@DQ^`M$qcDe*(w>lCIP9{0_rAZATiTsyIU0euDJR z^J2ajkEb*EI2ldx-9=~}&aaxJ{RHa`r%J@KG+r_RNiMd9Wd zBGxB?T&e3cM)2LeY@|bZ+7nhjc7^1sU8@U?sM~X|RDrXeKL;_hhkq^(DJARh^h66h z?Hnv~NMpf6nZre5@V&2|lUP;8)Pc|_Mnp?y5$5E6VQGRj%r8d-4>`5VH5s`cp`7ti z6IPfjY?_wN#sVtA(dE3I@A|66Myc~@c6UkQ{xq@2|5ybKeHuvKNZ|=X{%MghBd%V! zpUhu{@cVkexop|Z-kj|ZkZfEG3EV++eia{@`%X$|kFW^pL1SixI^{I9*=hKlGm6c0 z>ViKq_#DoxhxhMp1z*d_S9`5I(HAWcY~(BK9()d(Lu^mD3|e@=b2OsybZak7XZ#k8 z$s#%_tRFyFP4?#D0g#d|wI1Wdw>ZM=K+UN$D0n3$og#B^eS@-@Rkb2;_51HT6}c& zp24?seV2-8Vo?iv*VTeB5JZ+c+fA{nbep|&4`!`Sqgc9Q&J`82yKKME2g51_ z-{#Fw>qJ}4>RxuGyh`{%W^iDwt3ui2X4r}q0rmIx>MFQMmJW`6c33VFNauKzI=xnl zv1n+g8|!hlXDjp5WNlidfwKFhK{0seH|dN?yCc_GQw3JN_0;BWyP>;Qb}m2$E-F0i zmm*zAfVXvCSnPD#HjDqx;e7$BaFZq35(f;Ci zyu=gWg8Nw= zaqx0~DfB4FGE@9);qVrLEI%^yygAxBeKV{OTGA9!>&))5>`@^|aFQv?jzRG{lx#P^ z#$3FHp}hl|bi-SKd3MZ$k2nm-xBkh1RE6*1jOo_%Q&kf0nOUQ zV@9dY25*O$iUj+H8#Yxyu$k29pQl(1r+d=x51!2HU!GL*uhJ4GrY#s-ueXCF*0LZI zgbxn|&JQWM-WNVe$a(KC;Nu`l*Vplpz6+)=$iyni@iVwwiat262#<)ma8O=l0-32` z9Am{3@^j4SXt8MGH=O8Msw;S227$dYrbUb6@s0NMV!{QO=Au~+EWS~!{lRwAeP&k z|8g=B=*E!d2JK2tw|pUA`Zepl`GOnG3t}T>|L3NPvQeNvco9slDiyiHs+wBDu|-z` zug|pEs{L6XZwu28Te*xUecdOhgm3xOvUf^C9a5FK6D}UMBdL4%#>_BN2jIg3eYnNk z|4`@wD0`AY?8ZEL@+#_H7ox1hjvb$!6)Wzh-9dxS2S2^dc%#-au~y&TMZY6xPLYipElNFNB_I^9nyLbOc__Wo`A*3$eokW^&U^bJ_CwM~(&7M@0tKiVJ;~46R3l_Qa zCtSQ>#Q>~C8-(SpPDHA ziuu%CKmroh4(7krG-^$M=M~ zFCU*5KL;gTC*^wNy@#nssC-$Vv{z1nPZ`w#-pzfK+u;ciSX;4+$uYLp4m{0)OnSN; ziO3mFE^|j=jSy=SaAT*f0e9Y-!j$(iITdkX-=(aJuBK_c#fl*^D%=2+Ir{?Irrj&j zvglyPq#UX^oE*sU^q(7Qpy7aSiw$fvZ#V=KR|4Hxc9Mi_hMK6#oO{cNV&{m+-%K%+sA`PNT;Htm*hB-bP)2^(lHq5x0NZMal(BPH z$A}=b%J)og{x*U%Gcco$eY}bX`49Pa3s|`s=JFv!P})p$%xvF}1|Xje9(~3*b2*#7 zw+RY&0(nY$O|DK>ZCuA{t}tx0c=kg@NgxK8(IPk(A;OrVW6)s%o7sE10Pebbg`31q z&=WcgHIv4?R)!Z(T~q&r$J*C{b#ga+ZrRh&_wYQlNfSf0F%!(WiLf)$i{o5bTHKel zyz+3WLH&NF?rwI{1oSH@@XuTUCo*%`!4|Km(L~2jK0>0Wu3Pusq6GxFp3`9{O=yry zI=5lKj3J#XuYKDw)8EB^@)Y^p%SD`%;$<6LW@Dv9vqH$0M9`k$QMW|(S-98$Nw zQ(p6$POs6`GOHcJk46EoY17dWBd<(SZJAvEI_>4IcD4 zP49z6UVzZ8rW!f;VN{E$!P;SaftV?n;&eW+EZo6Dpb%S?l#?Ty!ZHmC!1Q-cBkE<- z_woXzCMm~^uGQ3))fFfETnw;+fi=vBo+)5uUFr=d zrwCF>%zZb0id;0)9h#vq>>qNX;`Y>_=)Qe-l)%3rFtj&xi0J(YIh*lq&LM1mMm5;R zRiwY^)RsA?IU4jD8jMX1O5^=Kozs~eK4}@rBhJNIuv*gK40)!MtcO$mb??vOx znrN=@&iAkM1fVrLI;5g`_N0}1No>h(DA-7pp{SF3Z3c);U81|LQEPh}HUKOt{Ygp$ zc&IpadMCci*%v}IExw3V>tMY7HKVNxkkp6TNRM77nr!rCOpH zA|9@cp6opt@VK& zVTk!JWCwHTy*z?2p&4teIA*!><7Zw1x%%o{{t+&cT3h=`+VmV4VrBA}_9e*#+}e>t zIQckpj+kh3Xq2T4=XDusQyhS?m<7C+?3*JOzKgj}-!mSt`Z_f5J(Y9vm{ITr6V3A3 zEVLixEKEzsC(s!dW=+=})hVjuh^1h0S5P0(_)QYsD;0Xjtb9EtSoMqGuKcyx;&m;>jj%UEK|qt+L!yN^{N^)uf{Uqcgr7By?KH!F0^3HV6@Snik{M z=i9-J<@Xd!u*%z?5(YQ!pCe=fqwDTuZFINi+zXxA#{}iPha@X+E|1mA`>gmZs#j`ksUDsa z|2p$YoTAwM$*M42y>IwV>)4BV=Gf}_;17c_9=mVI?kn|Wwy8C+);+$`k-)z5(20P) zwZ3G!X$kDzF>3kDj)vbhEzg&NcSSoBO*_uRIcuVgOhJ!E#qXfjED6$E5I8h#W6BpL zjH@e@y&$UWlKZY3?1!HmVu=@nf+A-a06b7`U#r1()~WPTT7O$j^=Wl5;Vm;p-emq2O0kdTR{@ z=gt^)W7Wzp$zo8Pc%_m_?m_TvsIG_-+p%va%3_wWPH`1~FZ6w_8#~hL4ocozxs-$k zNpeA5lDF?c^S7M>KF%4uv|=$t+}d<=$)V)w%%`bg0*S9A0bqPvBR>+DdZ~fB7{7;O zPOo)|<~W0>C|=FH%&QObJWpAyMN=T*NP=6mVXKKGl|1VnKk&%Q+)oX5nf|L&9DR@P z4lpYwe)d$=**aKeVCU{fcOixIw!3BTJ$`4R;xtaLRHYT=T!eCVds+Mts=zv8dV_)0 zH(URU#boI4{sW5p5)&qV54C1jV)|JAYPKj}Yp=YA&KuWJAJnW|BN5M9L0&gTSnDz7 z>@GsqxgejcdV9;nXxyF>8^l#x2daIAjvsdFjb^rYhq9ep3rbcVjF$%U{n?>~Phtr^$ z+}RE4-;5lMG;zpDu~!!~;C1 z%wgdpJ`=yr74PY3*x$}=9v}Zy8ZFQ__NhRi>HZ^IzB7KdFXF|IChPh8(UY}k>G#M@ zm4(wTd?_KXgr*?;>q_6%g4(nDd2kX)P@PM?6DVgGf2M>HtTI5or^9zqJ zop-(PkYk5VZi}z2qU6RtP>w41O_xd!jfx?TJ=#(Uit{S$7N?AA$nZtJgR}XVjj_u7 zf@g(~f1~dih-rajRDnG;npKTO4MXdAlkR;yI&*^#RfU~HSJK{2Px4h)(#~~K({<9o zOZ&vU@dH~LpA5p&>Pr7RDfiApwZn(?PY+j!_R>0N&caDIu=DnJ)9O&>J-LD6gK0rb z%eb>i4XADN65{d8+0e0?pHzEP*VI0#BCbY1mnS;`aQ%W(?AcV^_P{JwB4TW= zqngP^p52`FPdj8*fI)Z5bs*UKwr`+eyf3CYpYxk2A@NKP?u01e~$9z0%>DB+JHyO5&-*z~o#%PZ+2`FzO?3sLM|6(>005Da;yY~sfB*^r;6^;W zhaI^k)dXVyS!$~4$|<<1vI;o-Uwrr-SpUyISY&>O_l)?J*yWhLwAFP0?QeonB;U+m zj<77dX(m*)qu!9Pd4q*ZSSO7}zWOV&z_ZRFv^2I3dHSzgrol?Ht z9GwWoWxe*_;p|}1*t(7_(=KS{f{{^ww#K6W|G)p&8juM2@pb?4{mlJ1&%Bo;04I_b zVp9nM|EAP-zsvc0;Hg_S#X_zSE1rj0d z_YtR23=)c##ih{@Ck&)$2~0=$fP+?vEC5v2@O-T&G#WM{F>UGh!Q)7zq;g8~@u{$N zM8SLfkj&v zu7o=XLt7hQL|SOvWm$6g6wwKFyp@O-G8HDuj8Mm^i0z~XQ0p6n77|0jt!pxM8_Gh` z%!tKVgt}|pLomRKI>X~Kn!4mdw#}&f$hH&onxk^(vM>w+s2QgB!p>B2UkMG|yd+1Q zV5ZN7grG!CD7^OWfa+4SJL-$HmN1tMqod7E(Ak!Bm_-0L2RQ=ZM~y@h9p)?}bk!PU z3Wh`BrwwcvWh{GZuUT9yoF4P~T1va7^=@$KtoayGKT~3e8@S;53Z9- zRPtoeoUtS;AGdI2Q>1E7|w^_txX_Y1BJ$7U=G{Z7;nQyupH*6EFya^#$CjK)wG8%`7HXLF61(FV zl&T{fx|eqeree&Lj9VF?IBpdSL{ybDj>pl-j~Wq5c--cI6vtJyiHcm?&~5LGle1Db z0J3rPgwoSg@Va-=pF_U%p4P-8#o&i3=663C-00@iG@o-cZVeyBF2u4gYwB@z+Id!( zhE_iS05l6=GHq`jW^NcYGM>j^w`1`n;v4vz)R_^@JRtz^=H)o<4X!3|odE{`U~fsK z-3IXm3^h@43qovtPZxXd(i?(=%kOSiAO{|$sSNH-ScErkQ_PfxC*gXH_)qn-9?=T* z=u<+zMOfD}8M!nCS}Xsi=?7mI zv>fZr`R38eMJ}Fk(rV0{V4Bo7|YxUv4dmt6vo9GI|6+>Nok80o4w&7tvtU@ExGo5 zG9r-%3wm9)J#Q)PH2~X*Dg{JmxqtGogv2ke$1M{BVx*q;yGdC*l)G94p|Ed4yh=S@s8g%C6 z?aR~tBQPmAm-pGsd`{5FF1({@vP%Z(iaW%3B)U-~+Ckx4^izh;|qU#qKPMQI=o+Ijsn z-^=tF>!!vYo9Pce?P)@p|4bEJ77zkf6hWi}70_HLzK7HP&oDudoYq%#2XK(Z7&1%W zDx#lMl}cg9gB596>D}?y;tqJ=7gTxIAvwRVv^)XCo9@|DtWZUyY*#cubm%vvHM*>@ ziaKf^2e5owr_+$$5fgr}RLw3EWNfXL1dLLKn@oa9JIy@aBCxh~(KuyXt zl;s{>c2~FZgK@;4U3jupwP6XE%xM;Cs;ZsQ`ma7MBbm32HAaM66f}S$^-p;bMXvU) zZn(IBcD8M*OIng??dK7&*83vG10~yw=hF`0Msh6LaM+676Xotg}0W<`fo z!bv0H;E(H$XMG|8DYXsnY>BG_a{IA`4AWb-)Rj|V)~kbO640aW*AbFMgz*Tseb+y` zqLTkm04Q1D$qlWU*FfIlVxe`&M|?e4`z z^ri*NhrrN@1;@ISK4ZwzNF`qAA{iFv`Lup;T|aWF%A7Euf)4-7cB(5l9^bUJ&*4b& ztc)X8T)@c#2M`?IkMsdLJOzU=aztxHF21ZvgUbF5rpm6eZ(97p+wnhMh5In2y5gV(UfMq8m=I z>nZD84^J0;V}Jll%iU}d6SpR?>-?qf7_c*mt$Bu-&s#F5!drB&5jhh0I;ee@v-#@S zx8b!ksjIJg<8N66^uTfU63U3aSr}m_52PDP>4$WLGUtrrlr1c|mAQ^aBTs{;Z|pWC z6=hCE85g}eD;Za6FCfiSGusDkLW1OTPa4mS%QeJut5hXZip|O#OL!#)olj(LFz_FK z=K=oPrIT1r@_*?JjsCV4>^r_Lqjj1p$_==>Y=9~ObJrlL6wH?=bwDbEf&NMZP>{c<7J?@4mtibCy;6<_T)%O5ARVS9-E0rV;(j z=V^v%JVyzrffi(UIaq3u5$^51Lv7lxmDjgv{@wuYCEERb)oprIRR?}?CrMy-F82%^ zeR7GJ900+s{&)&kL(i_-@@^2-10N@@a^@jVz8rn>h>osn2qYaq8gol8`d-`uf>k5x z14qxY8e=|Cj3NiBlw$r;7MF#p$tg~IKUYqqeMtwwE7M4N?^V9_U6J9=PboPbM%tg6 z3e)2Qk5ZdhTn5a6+xfD_lb1d|yflA%Spz$c*ecCUfis&%v(IyPN*HI3tNq^P0PRH~ z^uBL(CH)lJUo}&9CZb>>Bt)nLbQN&IxydRxBS|#sH~QR-v;!J_utM4;ywP-3+r;1m(0#s z?W0Cf;87kYu?B{ItM?`1Snu|rop#}LjJWKTF_2^o9gYfCvL;%1;CtV`0&wZUhpEs< z;BwB(H6#uoWp0PhqbhGwx@eLPzJG>KU6QRb*@Z!q@$}44-CcrC?n$-JxpDiuGo=VG zwUII$9d@O3Tcn(gvUcP?!~?W#2J>`l+^gE-PsQ_uS*?A7Zb&v!(Rv_ht<7D=Zi0-5HxTX^Q3jJi^h4Zo z`Uu;`re=IYTYXo&KlRq(o(sy$=^hqn>e3FvIs?}2qGmV}feq4fK(I>%Q&|%5BSFLD z&EFGm5}X=wnWQBP2@=FNx#ibIo?pI+u9v;>QwT6C;5ip4hz2p?WI#HNPj{sHdmqFp z;ZH9i>~=4qgl+G>_Q@pxi(Xp9p2!KTq&TOI4S*CMRbBAR&9?Dr$V9V@W)8W&wvGe$kbG_VJO-=OFsJhx1H-ZjQdnF3;dza&-{eQY;Y+64bnEJAiBCh;#f)f~Ker;|#i zM#=f!FjXIjAcg{mTeJxEsKjfKLvgV9%l!wokDl~E*uFHT=D@TzFIx9iJ~1j&TzXW& zch{rcI-TWTghLNI|X;P+pHy;8NRlq0j}Kyv{4Mp4kBr{?DvtE_sN2E{yKN z0}^&)&T<1~z9M_iN~?gNvV^eL|EAudG&(1U8@>sCa?WH1%JK;u`?0jHBK$s@WuZK3 zq!#Z)@BVz!A?|`(4ugR-#S!>< zBLN<=oqcj$nF^|!Ia+(TvGGN-hnD5<)C$-n2N;?2W3fE^j(ibs*q7>Z zH87s@0qsoLO|Y69jhHTiIA>)7EvVx`LE}DM{P>dVI&Q3^Wenp|eH&VZy!@3`hIi$G zvz3@j81na|l&{A3271vfNSg94mJ^(EPAP}@M40K+nR?2Q^y75hk;8Tq<%l17i~lh1e)Cc!|i=t)_2X;X(qK31w12t zK$yZ^S_2;!8fQFE6upCPHP^01hw4p2Bra>N1hv+=r#^~jxd}3;qWe1g5t-rA*PoqH zX(L^oCBJQT!#_p0V`7!X{a=V~=J2_#EvJ%R9C?pC2~D1Ry1(Haj@E{gyB|<0tsa*v zr)H@w&TP%4mV%{AE)NUYz5vS@9Mn#{g;zFtc)ZS=NvY1WR_6!l<4LC6%gRro8#2b# zoGYwueJMYJ4#`Zx?Oq?Ow)`!=J1`4uAFvP5`h04wv{Au4F&pS4kAF7#I(1M6llZgR zcwm40Wm71uI|)dCRjP;>;=JDF&T;xq7lP3J{tPeTF9zG&~Gq@Ff+HS>hU7fO_4PJ`*V)Uz(F9&5V2MMjE5uG{$_svG{@XL=TVS z@3lbJsZp5-$9Lw@xq+o@o(FKDK$$Yfo$4#Xid-d$Z0ZOz{np-OxZ_o+0wNCH>vKf@ z+Afz7{grah#l<9tP`$xAt`bL3gpJ7Ley1}@+rdCxw<`6wxK1nek0nKwQTiK<{-PGg zV6htP0W*!Wr=k<^`bV|oVtm5Y(n$f#KAkhz=P~V$tN8I_vh7;o@BUregBsgpaeBI& z*-)w04YKW_58DGYh&N=;_y~RX5Qn-iAWXXT#^3`k60h3ktHJm2i_8E}I0j>i>1jdJd%8x7AqXR@6 zXDq_uVe4_ZIatH5%H za|NcTJWjXX99-kLC{f~J(=7g+DJ)aT%Z&%VLV;vm!GbQtFUcHon?tbg`W46^bSc%H+DEX9#IdYS2^quB~pFiONwiRR7!atNC?0s zlP~Ure|xlWE;|;8kgX$XY`i}dh&-A0c&BS)(^=^_;{V|(R7z;HLqi=9D@?~JjHEq@~F7Ie1W zBy^p@j|6_?^HGaeD@%$6tc0-3SHv%F*sW5OuWOf(>0>umhd!-u%8GA9rein0Ly5kl>uY6^ihSJ4ZrwIb0cVh--@-+7wzjwzd+1bU*rp!*a%;s){?Pt ze>4rGD7_a*L99tCr-Az>qKi5m;k3-X*1vhaT(}9%) zZ}Tv^jRBE8zjwtLXC$Mzl03&0SbjfneK<8alL&)KMz|b-F#;cfuV6xiU`khfIW%1d zN|y%*b=7%HG|f5sN5=fXGAjkN-YB3o)wfdS@2{A7ySiWC^GfLv>SHS81(P|RNY3r$ zG@*^!_hoM93>5-&=NRZ7Y7)6mQjy2)vH_is96m5>cq^6XJifmNHla zPphZ(SRxv-T?BwXC6S$uDTKqBpN5Qsy7o5;&V}T%*N3Tvjnmsfg|I*_9Po3D10}+N zHr9z4eB87c)Ia38*5YBFg0B6!nzG)CpZnyj0;!$a1%e2O!xp&6&QG)<;Xnwwzok!u zW0@QFv=@x=K$kKgR-cw$Xn{v3R>Ln<)A+Rf!n%1;as&=;AHl?6>38hb7wTYb(sfwK zlSXeecG)yG3m^`7N9{xgJD=PIdK+$;UMU^MFEK`Vp~=$#LM+!RwCM@<`!FH`AHxVf z0;7RT&ifnAal~NjWx_+zx_|L9790NCk)Ju15Jv(C7rXnWxH05P!f&k{jOH zmA&4G@B4Nq8$ZKaCAQJ`R?2!lUa3Z`0AaLU)UWp^SIT_QQK|DlCB6(Cm#dp2t(F8Y5JsOFKj(O0Fz$Ui2#mk~hE63Rum zTX@k?&j_}j8>&4VH$JWHa`*rTr(U>3lwN5SS2EV<#r$0~|TkTBJ~Q!B4>+3tGw@dKFY z{fZoW$w!)Ss2FwrqH5i+OYC&f_tn6J%w}>uo586kXsdr>qchdD1ki|z)&OTOadnwhJpvwuleC1Vs@#)I8VFsxn17!|N``p; z;1dp%l$GVI0|c)?Z7vfUJMvqClf6=FgGpmVuXnk0QVES&Wrm2@zaM*G1X4#6S}lC# zE_o7)KV_W8$@sbq$lgy>MCXm9;z6|>I2=RZ)vGd+p)I0^%hB@pL~a!v1wM*gYb&N< zD~Mh**B|~UsqiSuE0cTc=a%8rDSXg7*!^{Dbl$aG42#HlARMc4i3ifvXW}!V)+X=v@!HsjE9qMHMHEa(j)-j~6*sIvmnxLQjF#H<&p%5!Br z6(}M8Hn-C(F{FB){N?bG`=o!Mxr0MOE;|!qgfdzlbtX{v2pnG82^!=z#b)hmPnQwl zrmJP{og8eO@N>f(GNoPp5WTQ!vv-z>sLBqu|7H5sJ9{|#6Fcs`ZHy%@^!?7jBSe*P zu^2i)D@(QhzMQXdf5!0jHYKM6=iWZw1MrI{t@>D3G(GQB<^Gz5D7SiPJgZlFR+Pvn zYW47{p;Bw@@tIM<13`nc%H-L7kEorqM1>U>PMi-S{oyDWxGrY zy<1O-ushhXSY((oIjKf{{XG6g^6}l3Iq;WRMzcvoAi$+RRFGXA%-PB0^yIMrUSNS2 z2E&y)MSFXU@N|&Kk#gkmK6ols_%;MMacTY#T%4AH^(zN6T+R>O$+rx9Ifb;+65?Bi$&`0sqs`pb18(3%Bk4wnLvTis?#Xu*Tlf zvqRAprflAPT1ZwU{h;D~?<}bz5?l_d&%vAy_vYUXF88evx=R^!Qimb9KEWpl-tb;w zoJ3?SisoBY3aZX1NG`+J2Hl&!^p~sL}kY;@p%>G?4z=gtNZR3S@xUbW# za9OBFNiEXmaLpy)fD`2CyQuwUCm(`$1SyF$M}2vBzFvP^Y@u$9>!_MHbGm=G z$R+5>B#bQLqb59myYI^fBdUyct)Z_*hJ5{lHM#=ddpspv*l48}+3pKT!&H5RUmt+M zi1HOev7^#YaIX$g4;u%IHo{(X^54y-e%@tJc_9IH8Yxuo&o4jQ6H)MRr&+|kKw;Lf zt;CR~FA)C%>E!aAv#zAEzrW+z*g$Ejj|x4c1wmoLpMtT6xG5n|G;U0rI(pX{>_?ob zd{)}xzbQn)qnl%vyl=Yj5W&qE6zKG2^d&KPur~E6tSKaw`$3|ZfL5~t`_c`q?oXlY zlL<&zzqq2-+P6)M7FWG#^J|T%A>wHDFI;EpIrwY4Vfp}< zE4?`RQj<{WK=+?kS$-}~7`B$er(GhcMs{F+s)KVlkd-6W8g-I(US_7EH?|FW!S_MU!=U^IN$h)IlP^IR5MK^=YCq(P}Rs+*ZYwsZUOs$WPf18PvAQ{yzlVD z3tQOoec?AO=D?h88{(dY&woFOK2!eMn&jOB*HPA6%f4aoMw62xJPJ**=Skyl+GyOc z7L5{cE3v37W%FX*;x&nG8G}3_i{QsJnt}aLP`Nl%Z$st>62ykF@Yyk~q}U-ds!koJ zhHeH%y~tdOuXjiMjbUzdx1M2M-x_yST{SEt-p!+WG4#Jv( zz}59j8mP?xqiJegtH!Xkc-J77?BT}JG$Qa_wXa!p#qJvlB7FLXyz200mv``%-)gmt z)US5Oj5M}Z?mH3M3kw?OckK?uqQx@PkyPNQKh)gMKoh0Ybg0P^9OUB8mOeC@F6DAI zWx-4Z$_V+e`(r7a*!HD(#ftWK^MxmGAX$r)Z8dDW9`CF2Taz+WHK~NJgXzm~V#AJr zG$u`Gv0?;6Bs?Uax)LqsI2h+ksgpQaE6d2X*;>QkM)1{O$jf1Pqokn-a|hM&-|sG& zpyT@mg3dY)z2$wZZY3OVgJ0KcZTJoGN0TNtbk~*9W-^DpqU0%pSaPNE7cv(NG(WC? zRfhPh;p^qZn*K_eqd+JzI^}q{ovh|RD6d(^#U}F_aou0O)%32}!bZ7>YmKMmSCyPg z{v?dY-rp$JRiTVu@o}PowNElXkcC(t*Ei6-JWXDikGr0r1dkCNNIrs`pEK3DmWPaVY>X_pkc#brfsh)H6 zZA4}@sI;!Ug~}DSIt)1C6-o5(ODtSx=_D`d(VtmwDwtNbH~`g->p?J43NS!Y0=0Jz)}9v#Mn{=J^tkv!`!VFnto3JrS*%)$;hmn(U*#i=a!0jw zpa+l<#wd8JHmB3EwK6O%t*f#d*)%k+t;n6?)18`)8O!F4fGg(xTxK++SwD5TnR>nb z241`K*@q=;GG6`ptLiN+lWim=7!oL%ij^UWzyzB1`O3ya>hPxz6Tdh|DOsOk;3Ty+q2H^Y7ssz+#s$GQA%hY9Rf^GD{t&A$k zE+lqbuCp3o=*?#`mu}<|GZ1=QurUqI8qec5IQG`JmuMEJD-sa8sqaqpd*gV6RdbV^ zWp&Dgf`hHEa>!^J!w4MjghqWje#N_zrLoFFSB?^jhxhN^x=>cA`=y`76& zc)tE~;im|n-6gD^4+CgI&}2E1HY~%tAZXT}0aBUJ2O?WkM!2{%k!t=c^#bX2X`J{< zvrmA>Kg>7nRmvuT5b+F_H+{_nj<@(cnSj7buQRwE^&^gJ=P^W zy8_Mfn#M%H55gB)(X-k2KSuyRYZbpNkCBLg8*L?fTIbhTC_-N#+G4}P2#+@sk{=vw zm}vhRST}m`d+_InZ7IH4Y~xn5 z;v-rpc!!yFtz7X_mg!#t@6)#;HL|z~^5zkUg^7a!}__f4}}hig%Tp_=_0X zXKu?vg~UeRS1msmet}qo`*=vq&91vWMP!EJ2W8AzW!RtY)^mx+*soaI)hvDvfPRq9 z4Qd|U74NQ*DrhU5{c1Y=6^(&CyorE=&nVN-7#h?LI=Er#JL1XlnPn3-WAsgyp#$#& zz1Y31i-YR!e13702p}jjxj*BajB{bOyr&4o9^4{Dt)yNTI184x1Jy@gL>5Z8-IYF< zm24_IZfSvAoQv@g6Z!E^m9(}`i4lSsyB*hRnC4m&Q_qBFe<^PX^+a8(iZZep1YCVs z-`&f!_kPJjY%NN;6l2}y*6hVc?QKZaJ@w#WK#0T5nJ8=_=(;7SZe~6U2e{NL{(GkB z8uL+QD`+3>Y-M>vqr>>e$xd=h)(KBox!ed9~{Q82F`F_$H zoy!R+i!&kqYV0ua$koMJ1S^#92QYGFz&yjh8bg<6gq?uUzD4Oqx5)XwyBNEej+mr2 zX2Nz&#QbJDy_%t#Mm)ZKX=5F)3#;D1_J6Rfw;Ner&5BErIh)|0BzS#2#FE~=#7mq= zn7$?K>HWPUrSd+Y&iW&EcwZA=SJUBM-|?p~E5T2(oKMD9UlmDU8*7{dKUr;()(4of zOG>G|tT!mZbb37j0L)qPW(BlWq}0cLuzp6C{$4f2g7iK6F&9%rHV*4=mFP{-R(pFE zx`XWuSx$Y?s63|l-ZA7UPhzqr%za)G+j%1b3+BsarHI!7DE^=^S}-#hVEX!{1RvYp zk=FNcREC#-p=fsa4ePsuDfFjZNAnsierBYx$c(zBqJy!0p+3g#&w2VTcLv)Cb|y|c zVzu}Nq4ouxZP6CVsdzQ_JiuYQ9@`Ska!|ROXIsdEC~C~FB=Ypq&)c%G>cj!OIp@J0 zGQZCyKF^x)t%96D?EMv1S`rpPXj9~2-jh@#H}L(v@-IT+UFX;?q9!&0s9t3hBsy3+ zgUx+Dv`pH1mv9c-C$%2ihuT1Wqv?=utD{>W%+x#l0qsw(u)u-^XMXlZdo&ejn)B?E z)3L}nLQ-Mt*1L`!zwQBoojy<0G32bWp(6;IqV5-pG*~0Wwo8Su9b0Y@=VhWURzf*c6l^y~BhSd>&*jxK5P|u%>VI;I$B8tL@u)0;TyC}Em|MMtb z$Arp9i87-T+tIcDiy}w1xvvyeZ06;36KMYXMf|Jg2Y_HGjfKEmQ10Rg%j%z~0GpbV zg&%css5GpJ9*Wdmy_y@Kv}{Q^4Qt$#WN2iJ8Dg3>HR_L1vV{d|vBwbBSHl72!GD5PgCLw;As_ zjL=^yGgJ_I(+_*|b`DtVa<%)9l{cM9$HpFE-__)H3rnOht|a!dB-l(_- zlq)u~VrMkhJo~Xw-y;M1iyzf8y}~1zGp4^W-g~7jzdPxi4oh48oP)LP^B!F?vVN{H zsMC@2-(}4^vk$`jIvUlLU$c!&Oc$k0TVS5YUJ!q4H1UP2>mGNY9RMJg?p6LeGW zwn*U_{|n*pv+meehTgvJsM06CJ&5i*ktSP8JbTjr&wv;crI4-O~#p=F^TzQO^uNTuJ&q{FSvXl-?U zNwdtB<<`&p$bqfyYy(9cKskO_`x982M9=w{JpC(cZw4RFa_i_VGgd^>#MnVOq1iU1 zt#S&CY?i|A+L_Fv&0zg0E^h9&f+jNt%eQj9Pv|5r1dh-6+W59!JROu!=}F*v7N-FDYGZTa|?Pv@F6AK zH)ddoaH0Au_L%~es{7yP6&6&TmUi;Y`&0_c@@ z$n8z=04#!0RFkXltjW1vzwP6<7i9 zq1=P-xwW$a+8-xHd&){Isd*=UbBT1b<{&L7jl259jQBgNRvZN{fs+BFJoFqQ3Un!JMC=Jjg*OA`cGd#I$6TvY7 zmj#gnf=#p};Z0Evy=e}$z}6^JN9)sE2m8uM``{$*uHW?;B#J4iYb9{U$+)7N-Mw3_ zljAm(Ols*f1!BMZfR{|OSB(g>otHmSSoBnZ5gx|yP4t}vEj@?$Lw>3 z=xbxAmi#$vOvoBxTEKy1)&5$n=E=A&EiK9A6L#0O%Eo-5*o$Mfg5@uYugbM39S7!j zUY;EZEnowBOxSs&gAsGFQS%1l&NFVQ^1EsA@v{kcmWVJ85+A2_C!FhB4NS`K3Z^aZXV(i_hVN$@Bg#-pHSb=KI!07#vA3Ds8QYb*Jh`aRwjz&- zNjWLtsOk6F_+Es=Ru}r~J=oq5tiM`TU343|>Uh&SxYrkq4X}6c^XED#--nb%`dq6| zAOEDI%WE#J0R9XPGnE|&T{i247acip7i};BzAaFUeFpM*>C3-Q87s|vu`i$V%b&t6 zcLq~K4$cT;{uu?AMM1!lMYBhW832HtB`VLSxG$5EJ_t_!Zd~aIO0|XGS}c(H1>GzR zvnE>Qh90|KWe#C}|6{|pffM5b;kNB&m0Mu1=x2TEocAuNrSLy$Ds(^oF=gP;@U5t^ zh1`H7;0@v1HSR?}m6BT4!%P9?{RH@uri$nxEDwn}0U~NZGa8h~brhhtk@5cXTv0I13)B8`lL({f2)pz} z`~W&&N*~wRV*UIL`P(%|(u?s&S(I8y6R%?Or^eS*`=#S{48XS#gbP9WK$S0|Ow7$+ zEyw&HIi4l&;JnXJ)UrDsv7YoFYaP_bBuN*=V{h)8P@;@!{jC<4?6qgu5^&0X`vt`8 z!&&`9%jrLQqKOvyK-#?lbtp{OTFj));PCCN#=!2I$$R$L(!@K4QpnR-l)Hy1ndhN5 zm0bv#A}nXS4src#erYf6sW`lnbV|k`jNE`D2VDHJEvVY1#PthiL$tgu~{JBS3j{CUY zMQX&a0r9Z}&TPqeWc3)k5m75;f3*EJ4IkRd(Pg`q(te_UrTRXkxnuU^3pu!#ASr;jagPks$-?&?547%&ua6>Wqbh7iV|0sQPMd#V=(2ZX^6E?8X#k zEi=SucJwW;AZX#`UwXhBe$V9ALDM@fd!NsitJ*}mE+Yo{XVz>87yi=vW{0eWNl?>Y zW)bZ7Lqe(lm_kRQ*1-Zrm{t|KooUwbTb`_;?|va)st`QCkPe_9KlB)xuumMDLL`J4 z?Ijv6Jpcf@9ebXmPb05dqlpgUVuz+YkMRd}{A>u1z3CUHy}r2_2j-*bF6&*0PiOg; zNde2@*!Pd7r=6ar%$CFBqZyr%lQaIe-SZ9MU-QsnsqzHpORaE$(-&>(gQ4C3?f2ow z$#mewy$VK^W1*9P1;iqeGRuN0AL1~tltXE+3Ml&Gh{M!Bm@GY*=0=zMpe>U0LIC93Z%NcnPKZ1#pUQuO1 zOm&BRt{0im3@`A#thXON#?;Xp5&#mo?hPWM3l!V2 zmD-RPv6urkD5GxUQg&%h0YV^rbJN-X)!$-FUHY81g8im7&Z}dL#AKr$9@(5qT>12EUm~4qTjflp5;O6LD zj?qWt%-ZdN@y_6(DmnIW@0SI$|2P4XwSjr>zslIww12R+N2+yip5oa0w*&V06cuhw z{ap?4*kS@n)~+KbW~c$+p;+#z#v#a4P0M*^PO-P8AX(o+m~q#V6o<-l&vHzJn2 z-kB_W)m2rNs`UMfyuZv(dQz*}>0SG8;Jy3|E>p;+4RMi^KFnS`!{rkifR{k8!DU?N z?2hT%kwnK19Oh|Vs`ZQ7s>=Ef;n2Do#kf8tY|dU#^`arz?AsV>WPS&|`8lf^=rNn) zr*jwthAt4sbF+Qs^r-gvr-H!(ho=Cs2LDraajFF4sXSLqjh!pYJ@unaaJjBZ~ z@x#YuiA!jTi%1**inGh8iTZ(Us^;UgYh4*oDAUco)xDH=OLiJrD$o9H&PV>w%+H>& z?m3=F5pW_fHIBYX`@`>KInIH9q~|l{e~qa@50(Ic3I60X39*D&dj`|a(GGN}dGU(u z6*cxQjm62rRLsNg9$+L)QMAR;FnNeWzK#Z5Bj0FnFx$q0sEjo%`0%DS44dY^5oR{Dzix2Tnl75n8WMR6#&Bx=jRN6vt+!-(8 zr1si&wi|TF^38>E;FKN^%=xZW@CZvoqs%raYif`z9Ju!k?E9Ao5haj#pSIBnhU9%1qZT}EFidaO;761eUo7aj-UxeHsDE7V63f*q)?KIFU84-M z=aCf86YGl{A@6uEw0ycQDZu_V)STctyj&_5cTyw?lKhVPUN9?u9mV7cla;|a=t}D= zLv(e`4Q=GU{KD#)|%`XyB3${lxmrG3}tyTLSLq*z~XDLEYvp+(x@sA<9WY6`Xrg|D?rK|07-q(X@63 zH#~-mSvnq-W~u-4B5xjT_rnCZRXWCPF5u zF5W7DV06npdE~2;72~IauBqo{e*3#~*)FSy3gmbkJ^fj7!_A)_nAqBve+GW6G+7OI z{~$3;lRi%weAK!2ZtYxktWq2kn?7w^44)gG_`3r)Lb!CM{G*NuO}5XEFnsOXUt1whWuDNfB(gs@ABL_ zI@6PIr@4MUQ5Xlnm5@o5v1`JbBU3iadn`p5Z<&Qva`WM~F;w!^LaOyB=hwqo?y`)V zwGvR^&W#KX_A(N-cxZ-KT4z55UUC2C<-3@O6%ffDzw7Ahf*G1TzRmKEo0uIp?-BeL zE=a$WS}N?Z#>!GMw$X$NOiolOHXYKmlW;LjfSStTk}gLJmuHBn-`Mwq|JhrWk$7Bk zjG#CkO39?);QMT+*)qam{#*-|{BxMA*8Z#sl4R$ddLlww{I6`?e*eYecMpB7rd%&j zf|BV>*-~joqM1Bb#whn>KjWG!HD~Dt-nDf+hunK2?_xZ5G?2D6pS0zWp?ws#zbtLcN?cxHK?53f~;EXJwh0)!wxWM@LUB zN8M9Q{E}#m{{WmqYLj30Vqpy(RHt;7&Rrb;Gx*odEM}1d@Z2m*!fv)eQpBLO^$q@zT$<;yAtWjL+uDwypsK=K9dRPE+VDEi=HTI2iC9K4MvXl*f zmG!^p18_Ljc|8-g<`Y9mWU%n7lf8oBXjVPF!x@q~ON)+kf1)sXT%j~h4Zw?jo->)3 zOx6hTm{OYL@0M$#3jQef_*L<{y;z;Yyfr8H6|G7B|Fx$#f9NqVoPTiJY2~jwzzAFL zYKBrq3d@3ssngbneANn^9n!Md_3TVVhNo!)`*cf=E}E3^BJ8K=adZ3ZQX{4f?aED) zGmfgS`<}k0nMEn?xr2B^602_f%X6#WyqW*}_}*sVJkm8&20xCn7b4d**vO!K`N)mv7-c|HGW#j}=un> zfvH;bH&MCiqP0_MPwrh8b?;2Z87+p@mp+xQe|1gozWt`@FG^N#x0eVmz58S<6L5r6 zB;D=eu5~;2o$c@6+jt{pezsw$+=0@qO;!x9T{B!?wme<-s`lfz4?na^?tc8c-a|Ui zUL@R;K_kdz|K7kLc?;S5ea$b9+dL19{rfHa(8ZpXex3MRlYt3)-qXL|_9oO$s^9wc zV}XTF>FmuyH+a8(`5S(0*VM||M>)C78{S@K{QmK#*z#QrQ(mM9sa9WowS)b@KbF@o z_e3%TEV!tnQl`CPC3C>MX2I1Fchl}L2RypjAh#GH z*f8nTyOwh9_hJld++*8px$O*C95VZUy}fDp9q8C|H)Ctc)_iMJWbjJnQmr=ASj`+T zWAe{$5f8*H82H#87{-*dEHH>ly0N|absxi&)Ss!xJvZF=%2j?>kl{H~fqD9F29dN% z_cIC+IRQ&o3pOk=vD0f&&Ff@-5Wyg)I>VIF!Bel>eA(uNjh?rp7&W}!Zc?@`|H7!t zFo(;bQ(q#@mmwhJQ|ce{+aaF&7;dC}S}Hj+_mxUKaMtRlkhYoEC)NXMlh-SRY{jUEGYlJa8G1s{}T`%dV+t$1y1na ztlYErRZUFVd~V+Ypx&s@Z*T7IzGTI~n%UIEBlgqAcd4KzLs;PM*B!3!O2ivP{aJsp zrZe&FyYWSdpKl+7#)$=zY~6p}J5FJUIQ!$~Hj_?4>GF_B_5~{Q?;NpTeSAY{uPfhy zGBdT?LSmK-YqlHSoVMWv + Kaseya BMS + diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 52df55dd4726..66ff5b1a482d 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -938,5 +938,35 @@ } ], "mappingRequired": false + }, + { + "name": "ConnectWise PSA", + "id": "ConnectWisePSA", + "type": "ConnectWisePSA", + "cat": "Ticketing", + "logo": "/assets/integrations/connectwise.png", + "comingSoon": true, + "description": "Enable the ConnectWise PSA integration to send alerts to your ticketing system.", + "mappingRequired": false + }, + { + "name": "Autotask PSA", + "id": "AutotaskPSA", + "type": "AutotaskPSA", + "cat": "Ticketing", + "logo": "/assets/integrations/autotask.png", + "comingSoon": true, + "description": "Enable the Autotask PSA integration to send alerts to your ticketing system.", + "mappingRequired": false + }, + { + "name": "Kaseya BMS", + "id": "KaseyaBMS", + "type": "KaseyaBMS", + "cat": "Ticketing", + "logo": "/assets/integrations/kaseya.svg", + "comingSoon": true, + "description": "Enable the Kaseya BMS integration to send alerts to your ticketing system.", + "mappingRequired": false } ] diff --git a/src/pages/cipp/integrations/index.js b/src/pages/cipp/integrations/index.js index 60ee764853b4..6d3f24f86ee4 100644 --- a/src/pages/cipp/integrations/index.js +++ b/src/pages/cipp/integrations/index.js @@ -68,45 +68,83 @@ const Page = () => { status = 'Enabled' } - return ( - - - + {extension.comingSoon && ( + + Coming Soon + + )} + + - - {extension?.logo && ( + {extension?.logo && ( + + )} + + + {extension.description} + + +
+ + + {extension.comingSoon ? ( + <> - )} - - {extension.description} - - -
- - + Coming Soon + + ) : ( + <> {integrations.isSuccess ? ( { {integrations.isSuccess ? status : 'Loading'} - - - - + + )} + + + + ) + + return ( + + {extension.comingSoon ? ( + cardContent + ) : ( + + {cardContent} + + )} ) })} From c9bfe909582d9cc9d308903d7e74b44c6d926087 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 14 May 2026 10:44:12 +0200 Subject: [PATCH 058/164] feat(endpoint): add MEM enrollment profiles page (Apple ADE, Android, Autopilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new "Enrollment Profiles" page. Autopilot profiles page is kept, but could be removed in the future. Refs KelvinTegelaar/CIPP#5941 (related work — does not fully close). --- .../CippCards/CippUniversalSearchV2.jsx | 1 + .../CippComponents/CippBreadcrumbNav.jsx | 1 + src/layouts/config.js | 5 + .../EnrollmentProfileTabs.jsx | 466 ++++++++++++++++++ .../enrollment-profiles/android-enterprise.js | 14 + .../endpoint/MEM/enrollment-profiles/index.js | 14 + .../MEM/enrollment-profiles/tabOptions.json | 14 + .../enrollment-profiles/windows-autopilot.js | 14 + 8 files changed, 529 insertions(+) create mode 100644 src/pages/endpoint/MEM/enrollment-profiles/EnrollmentProfileTabs.jsx create mode 100644 src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js create mode 100644 src/pages/endpoint/MEM/enrollment-profiles/index.js create mode 100644 src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json create mode 100644 src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index 070396f0a56e..7ffa2bfdb725 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -40,6 +40,7 @@ async function loadTabOptions() { "/email/administration/exchange-retention", "/cipp/custom-data", "/cipp/advanced/super-admin", + "/endpoint/MEM/enrollment-profiles", "/tenant/standards", "/tenant/manage", "/tenant/administration/applications", diff --git a/src/components/CippComponents/CippBreadcrumbNav.jsx b/src/components/CippComponents/CippBreadcrumbNav.jsx index 560f84efb631..d890e84a0554 100644 --- a/src/components/CippComponents/CippBreadcrumbNav.jsx +++ b/src/components/CippComponents/CippBreadcrumbNav.jsx @@ -16,6 +16,7 @@ async function loadTabOptions() { "/email/administration/exchange-retention", "/cipp/custom-data", "/cipp/advanced/super-admin", + "/endpoint/MEM/enrollment-profiles", "/tenant/standards", "/tenant/manage", "/tenant/administration/applications", diff --git a/src/layouts/config.js b/src/layouts/config.js index a9df0957241d..c34329156da5 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -564,6 +564,11 @@ export const nativeMenuItems = [ path: '/endpoint/MEM/list-scripts', permissions: ['Endpoint.MEM.*'], }, + { + title: 'Enrollment Profiles', + path: '/endpoint/MEM/enrollment-profiles', + permissions: ['Endpoint.MEM.*'], + }, ], }, { diff --git a/src/pages/endpoint/MEM/enrollment-profiles/EnrollmentProfileTabs.jsx b/src/pages/endpoint/MEM/enrollment-profiles/EnrollmentProfileTabs.jsx new file mode 100644 index 000000000000..73fd35519bd0 --- /dev/null +++ b/src/pages/endpoint/MEM/enrollment-profiles/EnrollmentProfileTabs.jsx @@ -0,0 +1,466 @@ +import { useMemo, useState } from 'react' +import { + Alert, + Button, + Card, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '@mui/material' +import { Box, Container, Stack } from '@mui/system' +import { + AccountTree, + Apple, + ContentCopy, + Delete, + EventAvailable, + QrCode2, + Sync, +} from '@mui/icons-material' +import { CippHead } from '../../../../components/CippComponents/CippHead.jsx' +import { CippDataTable } from '../../../../components/CippTable/CippDataTable.js' +import { CippInfoBar } from '../../../../components/CippCards/CippInfoBar.jsx' +import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog.jsx' +import { CippAutopilotProfileDrawer } from '../../../../components/CippComponents/CippAutopilotProfileDrawer.jsx' +import CippJsonView from '../../../../components/CippFormPages/CippJSONView.jsx' +import { ApiGetCall } from '../../../../api/ApiCall.jsx' +import { useDialog } from '../../../../hooks/use-dialog.js' +import { useSettings } from '../../../../hooks/use-settings.js' + +const pageTitle = 'Enrollment Profiles' +const appleADEPageTitle = 'Apple Enrollment Profiles' +const androidEnterprisePageTitle = 'Android Enrollment Profiles' +const windowsAutopilotPageTitle = 'Windows Autopilot Profiles' + +const EnrollmentProfilesPage = ({ children, title = pageTitle }) => { + return ( + <> + + + + + {children} + + + + + ) +} + +const AndroidQrDialog = ({ row, drawerVisible, setDrawerVisible }) => { + const [copied, setCopied] = useState(false) + + const tokenValue = useMemo(() => { + if (row?.tokenValue) { + return row.tokenValue + } + + if (!row?.qrCodeContent) { + return '' + } + + try { + const parsed = JSON.parse(row.qrCodeContent) + const adminExtras = parsed?.['android.app.extra.PROVISIONING_ADMIN_EXTRAS_BUNDLE'] + return adminExtras?.['com.google.android.apps.work.clouddpc.EXTRA_ENROLLMENT_TOKEN'] || '' + } catch { + return '' + } + }, [row?.qrCodeContent, row?.tokenValue]) + + const handleClose = () => { + setDrawerVisible(false) + } + + const handleCopy = async () => { + if (!tokenValue) { + return + } + + try { + await navigator.clipboard.writeText(tokenValue) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + setCopied(false) + } + } + + const qrCodeImageValue = row?.qrCodeImage?.value + const qrCodeImageType = row?.qrCodeImage?.type || 'image/png' + + return ( + + Enrollment QR - {row?.displayName} + + {qrCodeImageValue && ( + + Enrollment QR code + + )} + + Token value + + + + {tokenValue || 'No token value available.'} + + + + + + + ) +} + +export const AppleADEEnrollmentProfiles = () => { + const currentTenant = useSettings().currentTenant + const depSyncDialog = useDialog() + + const appleProfiles = ApiGetCall({ + url: '/api/ListAppleEnrollmentProfiles', + data: { tenantFilter: currentTenant }, + queryKey: `AppleEnrollmentProfiles-${currentTenant}`, + waiting: Boolean(currentTenant), + }) + + const appleData = appleProfiles.data?.Results || {} + const tokens = appleData.Tokens || [] + const profiles = appleData.Profiles || [] + const syncErrorCodes = { + 1: { + label: 'Expired', + message: 'The ADE token sync has expired.', + severity: 'error', + }, + 2: { + label: 'Unknown', + message: 'The ADE token sync state is unknown.', + severity: 'error', + }, + 3: { + label: 'Terms & Conditions', + message: 'New Apple Business Manager terms are ready to accept.', + severity: 'warning', + }, + 4: { + label: 'Warning', + message: 'The ADE token sync completed with a warning.', + severity: 'warning', + }, + } + const syncErrorTokens = tokens.filter( + (token) => token.lastSyncErrorCode != null && Number(token.lastSyncErrorCode) !== 0 + ) + const expiringTokens = tokens.filter( + (token) => token.daysUntilExpiration !== null && token.daysUntilExpiration <= 30 + ) + const totalSyncedDevices = tokens.reduce( + (sum, token) => sum + Number(token.syncedDeviceCount || 0), + 0 + ) + const lastSuccessfulSync = tokens + .map((token) => token.lastSuccessfulSyncDateTime) + .filter(Boolean) + .sort() + .pop() + + const infoBarData = [ + { + icon: , + name: 'ADE Tokens', + data: tokens.length, + offcanvas: { + title: 'Apple ADE Tokens', + propertyItems: tokens.flatMap((token) => [ + { + label: `${token.tokenName || token.id} - Apple ID`, + value: token.appleIdentifier || 'N/A', + }, + { + label: `${token.tokenName || token.id} - Expiration`, + value: token.tokenExpirationDateTime || 'N/A', + }, + { + label: `${token.tokenName || token.id} - Synced Devices`, + value: token.syncedDeviceCount ?? 'N/A', + }, + { + label: `${token.tokenName || token.id} - Last Sync`, + value: token.lastSuccessfulSyncDateTime || 'N/A', + }, + ]), + }, + }, + { + icon: , + name: 'Expiring Tokens', + data: expiringTokens.length, + color: expiringTokens.length ? 'error' : 'success', + toolTip: 'Tokens expiring within 30 days', + }, + { + icon: , + name: 'ADE Profiles', + data: profiles.length, + }, + { + icon: , + name: 'Last Successful Sync', + data: lastSuccessfulSync ? new Date(lastSuccessfulSync).toLocaleString() : 'N/A', + toolTip: `${totalSyncedDevices} synced devices across all tokens`, + }, + ] + + const appleActions = [ + { + label: 'Delete Profile', + type: 'POST', + icon: , + url: '/api/ExecRemoveEnrollmentProfile', + relatedQueryKeys: [`AppleEnrollmentProfiles*-${currentTenant}`], + data: { + profileId: 'id', + tokenId: 'tokenId', + profileType: 'profileType', + displayName: 'displayName', + }, + confirmText: 'Are you sure you want to delete enrollment profile [displayName]?', + color: 'danger', + }, + ] + + const appleFilters = useMemo( + () => [ + { filterName: 'All', value: [] }, + { filterName: 'macOS', value: [{ id: 'platform', value: 'macOS' }] }, + { + filterName: 'iOS/iPadOS', + value: [{ id: 'platform', value: 'iOS/iPadOS' }], + }, + ], + [] + ) + + return ( + <> + + {!appleProfiles.isFetching && + syncErrorTokens.map((token, index) => { + const errorCode = Number(token.lastSyncErrorCode) + const syncError = syncErrorCodes[errorCode] || { + label: 'Unknown Error', + message: 'The ADE token sync was not successful.', + severity: 'warning', + } + const tokenName = token.tokenName || token.id || 'Unknown token' + const appleIdentifier = token.appleIdentifier ? ` (${token.appleIdentifier})` : '' + const lastSuccessfulSyncText = token.lastSuccessfulSyncDateTime + ? ` Last successful sync: ${new Date( + token.lastSuccessfulSyncDateTime + ).toLocaleString()}.` + : '' + + return ( + + {`Token "${tokenName}"${appleIdentifier}: ${syncError.message} (Code ${errorCode} - ${syncError.label}).${lastSuccessfulSyncText}`} + + ) + })} + + + + + + + , + size: 'xl', + }} + cardButton={ + + + + } + /> + + + + + ) +} + +export const AndroidEnterpriseEnrollmentProfiles = () => { + const currentTenant = useSettings().currentTenant + const androidActions = [ + { + label: 'Show QR', + icon: , + hideBulk: true, + noConfirm: true, + condition: (row) => Boolean(row?.tokenValue || row?.qrCodeImage?.value || row?.qrCodeContent), + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), + }, + { + label: 'Delete Profile', + type: 'POST', + icon: , + url: '/api/ExecRemoveEnrollmentProfile', + data: { + profileId: 'id', + profileType: '!android', + displayName: 'displayName', + }, + confirmText: 'Are you sure you want to delete Android enrollment profile [displayName]?', + color: 'danger', + }, + ] + + return ( + + + , + size: 'xl', + }} + /> + + + ) +} + +export const WindowsAutopilotEnrollmentProfiles = () => { + const currentTenant = useSettings().currentTenant + const autopilotActions = [ + { + label: 'Delete Profile', + icon: , + type: 'POST', + url: '/api/RemoveAutopilotConfig', + data: { ID: 'id', displayName: 'displayName', assignments: 'assignments' }, + confirmText: + 'Are you sure you want to delete this Autopilot profile? This action cannot be undone.', + color: 'danger', + }, + ] + + return ( + + + , + size: 'xl', + }} + cardButton={} + /> + + + ) +} diff --git a/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js b/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js new file mode 100644 index 000000000000..826afd75f085 --- /dev/null +++ b/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { AndroidEnterpriseEnrollmentProfiles } from './EnrollmentProfileTabs.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/src/pages/endpoint/MEM/enrollment-profiles/index.js b/src/pages/endpoint/MEM/enrollment-profiles/index.js new file mode 100644 index 000000000000..e6fc7bfad50d --- /dev/null +++ b/src/pages/endpoint/MEM/enrollment-profiles/index.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { AppleADEEnrollmentProfiles } from './EnrollmentProfileTabs.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json b/src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json new file mode 100644 index 000000000000..4bf6047e9d31 --- /dev/null +++ b/src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json @@ -0,0 +1,14 @@ +[ + { + "label": "Apple ADE", + "path": "/endpoint/MEM/enrollment-profiles" + }, + { + "label": "Android Enterprise", + "path": "/endpoint/MEM/enrollment-profiles/android-enterprise" + }, + { + "label": "Windows Autopilot", + "path": "/endpoint/MEM/enrollment-profiles/windows-autopilot" + } +] diff --git a/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js b/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js new file mode 100644 index 000000000000..4c072ed7302d --- /dev/null +++ b/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { WindowsAutopilotEnrollmentProfiles } from './EnrollmentProfileTabs.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page From b19024214e96bd207640c5e63dcefb38d408e161 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 14 May 2026 16:51:32 +0200 Subject: [PATCH 059/164] feat: Bit more margin to make tabbed layout of first item less cramped --- src/layouts/HeaderedTabbedLayout.jsx | 11 ++++++++++- src/layouts/TabbedLayout.jsx | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index 1b5585a6812a..d36ca26f0497 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -102,7 +102,16 @@ export const HeaderedTabbedLayout = (props) => { )}
- + {tabOptions.map((option) => ( ))} diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index 031f363c4dac..c69157050da6 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -55,7 +55,7 @@ export const TabbedLayout = (props) => { variant="scrollable" sx={{ '& .MuiTab-root:first-of-type': { - ml: 1, + ml: 2, }, }} > From 6dab9339978f195dc8e6db35633071da8f16eb8a Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 14 May 2026 17:50:36 +0200 Subject: [PATCH 060/164] feat(tabs): support icons in tabbed layouts --- src/layouts/HeaderedTabbedLayout.jsx | 19 +++- src/layouts/TabbedLayout.jsx | 17 +++- .../MEM/enrollment-profiles/tabOptions.json | 9 +- src/utils/icon-registry.js | 95 +++++++++++++++++++ 4 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 src/utils/icon-registry.js diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index d36ca26f0497..c1abbe1d6e2b 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -17,6 +17,7 @@ import { } from "@mui/material"; import { ActionsMenu } from "../components/actions-menu"; import { useMediaQuery } from "@mui/material"; +import { getIconByName } from "../utils/icon-registry"; export const HeaderedTabbedLayout = (props) => { const { @@ -112,9 +113,19 @@ export const HeaderedTabbedLayout = (props) => { }, }} > - {tabOptions.map((option) => ( - - ))} + {tabOptions.map((option) => { + const icon = getIconByName(option.icon, { fontSize: "small" }); + + return ( + + ); + })}
@@ -142,6 +153,8 @@ HeaderedTabbedLayout.propTypes = { PropTypes.shape({ label: PropTypes.string.isRequired, path: PropTypes.string.isRequired, + icon: PropTypes.string, + iconPosition: PropTypes.oneOf(["bottom", "end", "start", "top"]), }) ).isRequired, title: PropTypes.string.isRequired, diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index c69157050da6..fc3f7e440773 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -3,6 +3,7 @@ import { usePathname, useRouter } from 'next/navigation' import { Box, Divider, Stack, Tab, Tabs } from '@mui/material' import { useSearchParams } from 'next/navigation' import { ApiGetCall } from '../api/ApiCall' +import { getIconByName } from '../utils/icon-registry' export const TabbedLayout = (props) => { const { tabOptions, children } = props @@ -59,9 +60,19 @@ export const TabbedLayout = (props) => { }, }} > - {visibleTabs.map((option) => ( - - ))} + {visibleTabs.map((option) => { + const icon = getIconByName(option.icon, { fontSize: 'small' }) + + return ( + + ) + })} diff --git a/src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json b/src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json index 4bf6047e9d31..9ed4566b1448 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json +++ b/src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json @@ -1,14 +1,17 @@ [ { "label": "Apple ADE", - "path": "/endpoint/MEM/enrollment-profiles" + "path": "/endpoint/MEM/enrollment-profiles", + "icon": "Apple" }, { "label": "Android Enterprise", - "path": "/endpoint/MEM/enrollment-profiles/android-enterprise" + "path": "/endpoint/MEM/enrollment-profiles/android-enterprise", + "icon": "Android" }, { "label": "Windows Autopilot", - "path": "/endpoint/MEM/enrollment-profiles/windows-autopilot" + "path": "/endpoint/MEM/enrollment-profiles/windows-autopilot", + "icon": "Window" } ] diff --git a/src/utils/icon-registry.js b/src/utils/icon-registry.js new file mode 100644 index 000000000000..a87db806b2f0 --- /dev/null +++ b/src/utils/icon-registry.js @@ -0,0 +1,95 @@ +import { + AdminPanelSettings, + Android, + Apple, + Apps, + Assignment, + BarChart, + Business, + CheckCircle, + Cloud, + Computer, + Dashboard, + Description, + Devices, + Dns, + Domain, + Email, + FactCheck, + FilePresent, + Group, + Groups, + Home, + Key, + Laptop, + List, + Lock, + Mail, + ManageAccounts, + Notifications, + Person, + Policy, + PrecisionManufacturing, + Security, + Settings, + Share, + Shield, + ShieldMoon, + Storage, + Sync, + Timeline, + Window, + Warning, +} from '@mui/icons-material' + +export const iconRegistry = { + AdminPanelSettings, + Android, + Apple, + Apps, + Assignment, + BarChart, + Business, + CheckCircle, + Cloud, + Computer, + Dashboard, + Description, + Devices, + Dns, + Domain, + Email, + FactCheck, + FilePresent, + Group, + Groups, + Home, + Key, + Laptop, + List, + Lock, + Mail, + ManageAccounts, + Notifications, + Person, + Policy, + PrecisionManufacturing, + Security, + Settings, + Share, + Shield, + ShieldMoon, + Storage, + Sync, + Timeline, + Window, + Warning, +} + +export const getIconComponentByName = (iconName) => iconRegistry[iconName] ?? null + +export const getIconByName = (iconName, props = {}) => { + const Icon = getIconComponentByName(iconName) + + return Icon ? : null +} From 9b6164999145da5dab8cb726cc2b4d6a25cba44b Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 14 May 2026 18:46:23 +0200 Subject: [PATCH 061/164] feat: Migrate to use shared icon registry for string to icon conversion --- .../CippCards/CippPropertyListCard.jsx | 9 ++- .../CippCards/CippUniversalSearchV2.jsx | 4 +- .../CippComponents/CippTenantSelector.jsx | 53 ++++--------- src/components/bulk-actions-menu.js | 78 ++++++------------- src/data/portals.json | 6 +- src/layouts/HeaderedTabbedLayout.jsx | 5 +- src/layouts/TabbedLayout.jsx | 5 +- src/utils/icon-registry.js | 2 + 8 files changed, 63 insertions(+), 99 deletions(-) diff --git a/src/components/CippCards/CippPropertyListCard.jsx b/src/components/CippCards/CippPropertyListCard.jsx index 4e7bb2d81f0f..01eb598409da 100644 --- a/src/components/CippCards/CippPropertyListCard.jsx +++ b/src/components/CippCards/CippPropertyListCard.jsx @@ -15,6 +15,7 @@ import { PropertyListItem } from "../../components/property-list-item"; import { useDialog } from "../../hooks/use-dialog"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; import { useState } from "react"; +import { getIconByName } from "../../utils/icon-registry"; export const CippPropertyListCard = (props) => { const { @@ -51,6 +52,12 @@ export const CippPropertyListCard = (props) => { return false; }; + const renderActionIcon = (icon) => { + if (!icon) return null; + if (typeof icon === "string") return getIconByName(icon, { fontSize: "small" }); + return {icon}; + }; + return ( <> @@ -160,7 +167,7 @@ export const CippPropertyListCard = (props) => { actionItems.map((item, index) => ( {item.icon}} + icon={renderActionIcon(item.icon)} label={item.label} onClick={() => { setActionData({ diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index 7ffa2bfdb725..83e127352851 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -392,7 +392,7 @@ export const CippUniversalSearchV2 = React.forwardRef( const typeMenuActions = [ { label: "Users", - icon: "UsersIcon", + icon: "Groups", onClick: () => handleTypeChange("Users"), }, { @@ -412,7 +412,7 @@ export const CippUniversalSearchV2 = React.forwardRef( }, { label: "Pages", - icon: "GlobeAltIcon", + icon: "Public", onClick: () => handleTypeChange("Pages"), }, ]; diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index 74447184f4ba..0b2c3ce62508 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -1,30 +1,15 @@ import PropTypes from "prop-types"; import { CippAutoComplete } from "../CippComponents/CippAutocomplete"; import { ApiGetCall } from "../../api/ApiCall"; -import { IconButton, SvgIcon, Tooltip, Box } from "@mui/material"; -import { - FilePresent, - Laptop, - Mail, - Refresh, - Share, - Shield, - ShieldMoon, - PrecisionManufacturing, - BarChart, -} from "@mui/icons-material"; -import { - BuildingOfficeIcon, - GlobeAltIcon, - ServerIcon, - UsersIcon, -} from "@heroicons/react/24/outline"; +import { IconButton, Tooltip, Box } from "@mui/material"; +import { Refresh } from "@mui/icons-material"; import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useRouter } from "next/router"; import { CippOffCanvas } from "./CippOffCanvas"; import { useSettings } from "../../hooks/use-settings"; import { getCippError } from "../../utils/get-cipp-error"; import { useQueryClient } from "@tanstack/react-query"; +import { getIconByName } from "../../utils/icon-registry"; export const CippTenantSelector = React.forwardRef((props, ref) => { const { width, allTenants = false, multiple = false, refreshButton, tenantButton } = props; @@ -65,68 +50,68 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { key: "M365_Portal", label: "M365 Admin Portal", link: `https://admin.cloud.microsoft/?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, - icon: , + icon: "Public", }, { key: "Exchange_Portal", label: "Exchange Portal", link: `https://admin.cloud.microsoft/exchange?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, - icon: , + icon: "Mail", }, { key: "Entra_Portal", label: "Entra Portal", link: `https://entra.microsoft.com/${currentTenant?.value}`, - icon: , + icon: "Groups", }, { key: "Teams_Portal", label: "Teams Portal", link: `https://admin.teams.microsoft.com/?delegatedOrg=${currentTenant?.addedFields?.initialDomainName}`, - icon: , + icon: "FilePresent", }, { key: "Azure_Portal", label: "Azure Portal", link: `https://portal.azure.com/${currentTenant?.value}`, - icon: , + icon: "Dns", }, { key: "Intune_Portal", label: "Intune Portal", link: `https://intune.microsoft.com/${currentTenant?.value}`, - icon: , + icon: "Laptop", }, { key: "SharePoint_Admin", label: "SharePoint Portal", link: `/api/ListSharePointAdminUrl?tenantFilter=${currentTenant?.value}`, - icon: , + icon: "Share", external: true, }, { key: "Security_Portal", label: "Security Portal", link: `https://security.microsoft.com/?tid=${currentTenant?.addedFields?.customerId}`, - icon: , + icon: "Shield", }, { key: "Compliance_Portal", label: "Compliance Portal", link: `https://purview.microsoft.com/?tid=${currentTenant?.addedFields?.customerId}`, - icon: , + icon: "ShieldMoon", }, { key: "Power_Platform_Portal", label: "Power Platform Portal", link: `https://admin.powerplatform.microsoft.com/account/login/${currentTenant?.addedFields?.customerId}`, - icon: , + icon: "PrecisionManufacturing", }, { key: "Power_BI_Portal", label: "Power BI Portal", link: `https://app.powerbi.com/admin-portal?ctid=${currentTenant?.addedFields?.customerId}`, - icon: , + icon: "BarChart", }, ]; @@ -164,7 +149,7 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { key: "Manage_Tenant", label: "Manage Tenant", link: `/tenant/manage/edit?tenantFilter=${currentTenant?.value}`, - icon: , + icon: "Business", }); return filteredActions; @@ -343,9 +328,7 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { disabled={!currentTenant || currentTenant.value === "AllTenants"} > - - - + {getIconByName("Business")} )} @@ -396,9 +379,7 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { }} > - - - + )} diff --git a/src/components/bulk-actions-menu.js b/src/components/bulk-actions-menu.js index ff9e8e1f36dd..18f1ad5af1c0 100644 --- a/src/components/bulk-actions-menu.js +++ b/src/components/bulk-actions-menu.js @@ -1,46 +1,12 @@ -import PropTypes from "prop-types"; -import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; -import { Button, Link, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material"; -import { usePopover } from "../hooks/use-popover"; -import { FilePresent, Laptop, Mail, Share, Shield, ShieldMoon, PrecisionManufacturing, BarChart, Group, Apps } from "@mui/icons-material"; -import { GlobeAltIcon, UsersIcon, ServerIcon } from "@heroicons/react/24/outline"; - -function getIconByName(iconName) { - switch (iconName) { - case "GlobeAltIcon": - return ; - case "Mail": - return ; - case "UsersIcon": - return ; - case "FilePresent": - return ; - case "ServerIcon": - return ; - case "Laptop": - return ; - case "Share": - return ; - case "Shield": - return ; - case "ShieldMoon": - return ; - case "PrecisionManufacturing": - return ; - case "BarChart": - return ; - case "Group": - return ; - case "Apps": - return ; - default: - return null; - } -} +import PropTypes from 'prop-types' +import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon' +import { Button, Link, ListItemText, Menu, MenuItem, SvgIcon } from '@mui/material' +import { usePopover } from '../hooks/use-popover' +import { getIconByName } from '../utils/icon-registry' export const BulkActionsMenu = (props) => { - const { buttonName, sx, row, actions = [], ...other } = props; - const popover = usePopover(); + const { buttonName, sx, row, actions = [], ...other } = props + const popover = usePopover() return ( <> @@ -55,7 +21,7 @@ export const BulkActionsMenu = (props) => { variant="outlined" sx={{ flexShrink: 0, - whiteSpace: "nowrap", + whiteSpace: 'nowrap', ...sx, }} {...other} @@ -65,8 +31,8 @@ export const BulkActionsMenu = (props) => { { onClose={popover.handleClose} open={popover.open} transformOrigin={{ - horizontal: "right", - vertical: "top", + horizontal: 'right', + vertical: 'top', }} > {actions.map((action, index) => { + const icon = getIconByName(action.icon, { sx: { mr: 1 } }) + if (action.link) { return ( { target="_blank" rel="noreferrer" > - {getIconByName(action.icon)} + {icon} - ); + ) } else { return ( { if (action.onClick) { - action.onClick(); + action.onClick() } - popover.handleClose(); + popover.handleClose() }} > - {getIconByName(action.icon)} + {icon} - ); + ) } })} - ); -}; + ) +} BulkActionsMenu.propTypes = { onArchive: PropTypes.func, onDelete: PropTypes.func, selectedCount: PropTypes.number, -}; +} diff --git a/src/data/portals.json b/src/data/portals.json index a4402305faca..874810c6d976 100644 --- a/src/data/portals.json +++ b/src/data/portals.json @@ -6,7 +6,7 @@ "variable": "initialDomainName", "target": "_blank", "external": true, - "icon": "GlobeAltIcon" + "icon": "Public" }, { "label": "Exchange", @@ -24,7 +24,7 @@ "variable": "defaultDomainName", "target": "_blank", "external": true, - "icon": "UsersIcon" + "icon": "Groups" }, { "label": "Teams", @@ -42,7 +42,7 @@ "variable": "defaultDomainName", "target": "_blank", "external": true, - "icon": "ServerIcon" + "icon": "Dns" }, { "label": "Intune", diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index c1abbe1d6e2b..d217c9c87d5e 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -115,6 +115,8 @@ export const HeaderedTabbedLayout = (props) => { > {tabOptions.map((option) => { const icon = getIconByName(option.icon, { fontSize: "small" }); + const iconPosition = option.iconPosition ?? "start"; + const compactIcon = icon && ["end", "start"].includes(iconPosition); return ( { label={option.label} value={option.path} icon={icon ?? undefined} - iconPosition={icon ? (option.iconPosition ?? "start") : undefined} + iconPosition={icon ? iconPosition : undefined} + sx={compactIcon ? { minHeight: 48, py: 1.5 } : undefined} /> ); })} diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index fc3f7e440773..33c861fb85dd 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -62,6 +62,8 @@ export const TabbedLayout = (props) => { > {visibleTabs.map((option) => { const icon = getIconByName(option.icon, { fontSize: 'small' }) + const iconPosition = option.iconPosition ?? 'start' + const compactIcon = icon && ['end', 'start'].includes(iconPosition) return ( { label={option.label} value={option.path} icon={icon ?? undefined} - iconPosition={icon ? (option.iconPosition ?? 'start') : undefined} + iconPosition={icon ? iconPosition : undefined} + sx={compactIcon ? { minHeight: 48, py: 1.5 } : undefined} /> ) })} diff --git a/src/utils/icon-registry.js b/src/utils/icon-registry.js index a87db806b2f0..2863958edbb4 100644 --- a/src/utils/icon-registry.js +++ b/src/utils/icon-registry.js @@ -30,6 +30,7 @@ import { Person, Policy, PrecisionManufacturing, + Public, Security, Settings, Share, @@ -74,6 +75,7 @@ export const iconRegistry = { Person, Policy, PrecisionManufacturing, + Public, Security, Settings, Share, From 983b48a1b1c35c329c9ad8405635c26e6859b0b9 Mon Sep 17 00:00:00 2001 From: Clint Thomon Date: Thu, 14 May 2026 13:59:43 -0500 Subject: [PATCH 062/164] fix: Remove accidentally committed .claude/worktrees directory The .claude/worktrees directory was accidentally committed, causing CI failures due to git treating it as a submodule without a URL in .gitmodules. - Remove .claude/worktrees from git tracking - Add .claude/ to .gitignore to prevent future accidents --- .claude/worktrees/blissful-golick-d405ab | 1 - .gitignore | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 160000 .claude/worktrees/blissful-golick-d405ab diff --git a/.claude/worktrees/blissful-golick-d405ab b/.claude/worktrees/blissful-golick-d405ab deleted file mode 160000 index 0710355e2ada..000000000000 --- a/.claude/worktrees/blissful-golick-d405ab +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0710355e2adac37fffe4c7eef48d6f2c3a04993d diff --git a/.gitignore b/.gitignore index 44dac6dd492c..2758639d59a4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ AGENTS.md # azurite __* AzuriteConfig +# Claude/Cursor worktrees and local AI tooling +.claude/ From 0c32a84eb21d3df3a719427b54d10821a94b40a4 Mon Sep 17 00:00:00 2001 From: "jwebster@protectedtrust.com" Date: Thu, 14 May 2026 16:26:44 -0400 Subject: [PATCH 063/164] Add additional portal links to Invoke-HuduExtensionSync Added links to Defender, Compliance (purview) and the partner center page that is managed by Microsoft. --- src/data/Extensions.json | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 66ff5b1a482d..7bb91b6165f9 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -614,6 +614,50 @@ "action": "disable" } }, + { + "type": "switch", + "name": "Hudu.HideEmptyRoles", + "label": "Hide Empty Roles in Magic Dash", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "switch", + "name": "Hudu.IncludeParterCenterLink", + "label": "Include link to Partner Center Service management page (partner.microsoft.com)", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "switch", + "name": "Hudu.IncludeDefenderLink", + "label": "Include link to Defender Portal (security.microsoft.com)", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "switch", + "name": "Hudu.IncludeComplianceLink", + "label": "Include link to Compliance Portal (compliance.microsoft.com)", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, { "_comment": "I have added this switch as a logic check for the Hudu integration script to check against when CIPP first connects to the Hudu Instance via Connect-HuduAPI.ps1", "type": "switch", From 186a2c6ea22589ca28f655eba01526ceb74ec2d9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 15 May 2026 02:29:31 -0500 Subject: [PATCH 064/164] audit log template tweak --- src/data/AuditLogTemplates.json | 2 +- src/layouts/config.js | 7 + src/pages/cipp/advanced/worker-health.js | 442 +++++++++++++++++++++++ 3 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 src/pages/cipp/advanced/worker-health.js diff --git a/src/data/AuditLogTemplates.json b/src/data/AuditLogTemplates.json index 1762fb2eb7bb..63df852bd318 100644 --- a/src/data/AuditLogTemplates.json +++ b/src/data/AuditLogTemplates.json @@ -450,7 +450,7 @@ { "Property": { "value": "String", "label": "SecuredAccessPassData" }, "Operator": { "value": "like", "label": "Like" }, - "Input": { "value": "*" } + "Input": { "value": "[*]" } } ] } diff --git a/src/layouts/config.js b/src/layouts/config.js index a9df0957241d..987ab9d8f984 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -1116,6 +1116,13 @@ export const nativeMenuItems = [ permissions: ['CIPP.SuperAdmin.*'], scope: 'global', }, + { + title: 'Worker Health', + path: '/cipp/advanced/worker-health', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, ], }, ], diff --git a/src/pages/cipp/advanced/worker-health.js b/src/pages/cipp/advanced/worker-health.js new file mode 100644 index 000000000000..fd23de33c031 --- /dev/null +++ b/src/pages/cipp/advanced/worker-health.js @@ -0,0 +1,442 @@ +import { useMemo } from "react"; +import Head from "next/head"; +import { + Box, + Button, + Card, + CardContent, + CardHeader, + Chip, + CircularProgress, + Container, + LinearProgress, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { + Memory, + Speed, + PlayArrow, + HourglassEmpty, + CheckCircle, + Warning, + Cancel, + Delete, + LowPriority, + DeleteSweep, +} from "@mui/icons-material"; +import { Grid } from "@mui/system"; +import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { CippInfoBar } from "../../../components/CippCards/CippInfoBar"; +import { CippPropertyListCard } from "../../../components/CippCards/CippPropertyListCard"; +import { CippDataTable } from "../../../components/CippTable/CippDataTable"; +import { ApiGetCall, ApiPostCall } from "../../../api/ApiCall"; + +const formatDuration = (ms) => { + if (ms === 0 || ms == null) return "—"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}m`; +}; + +const formatUptime = (seconds) => { + if (!seconds) return "—"; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +}; + +const WorkerStatusChip = ({ isBusy, currentFunction }) => { + if (isBusy) { + return ( + } + sx={{ maxWidth: 200 }} + /> + ); + } + return } />; +}; + +const UtilizationBar = ({ value }) => ( + + + 80 ? "error" : value > 50 ? "warning" : "primary"} + sx={{ height: 8, borderRadius: 4 }} + /> + + + {value}% + + +); + +const WorkerTable = ({ workers, title }) => { + if (!workers || workers.length === 0) return null; + + return ( + + + + + + + + Worker + Status + Invocations + Utilization + Avg + Min + Max + Last + Faults + + + + {workers.map((w) => ( + + + + W{w.WorkerId} + + + + + + {w.TotalInvocations?.toLocaleString() ?? 0} + + + + {formatDuration(w.AvgDurationMs)} + {formatDuration(w.MinDurationMs)} + {formatDuration(w.MaxDurationMs)} + {formatDuration(w.LastDurationMs)} + + {w.TotalFaults > 0 ? ( + + ) : ( + "0" + )} + + + ))} + +
+
+
+
+ ); +}; + +const Page = () => { + const healthQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "Snapshot" }, + queryKey: "WorkerHealth", + refetchInterval: 5000, + }); + + const jobAction = ApiPostCall({ + relatedQueryKeys: ["WorkerHealthJobs", "WorkerHealth"], + }); + + const snapshot = healthQuery.data?.Results; + + const infoBarData = useMemo(() => { + if (!snapshot) return []; + const http = snapshot.HttpPool || {}; + const bg = snapshot.BgPool || {}; + const jobs = snapshot.Jobs || {}; + const limiter = snapshot.Limiter || {}; + + return [ + { + icon: , + name: "HTTP Workers", + data: `${http.BusyCount ?? 0} / ${http.PoolSize ?? 0} busy`, + color: http.BusyCount >= http.PoolSize ? "error" : "primary", + }, + { + icon: , + name: "BG Workers", + data: `${bg.BusyCount ?? 0} / ${bg.PoolSize ?? 0} busy`, + color: bg.BusyCount >= bg.PoolSize ? "error" : "primary", + }, + { + icon: jobs.Running > 0 ? : , + name: "Job Queue", + data: `${jobs.Running ?? 0} running, ${jobs.Queued ?? 0} queued`, + color: jobs.Queued > 10 ? "warning" : "primary", + }, + { + icon: limiter.IsHttpThrottled ? : , + name: "BG Limiter", + data: limiter.IsHttpThrottled + ? "HTTP Throttled" + : `${limiter.Active ?? 0} / ${limiter.CurrentMax ?? 0} active`, + color: limiter.IsHttpThrottled ? "error" : "primary", + }, + ]; + }, [snapshot]); + + const httpPoolItems = useMemo(() => { + if (!snapshot?.HttpPool) return []; + const p = snapshot.HttpPool; + return [ + { label: "Pool Size", value: p.PoolSize }, + { label: "Available", value: p.Available }, + { label: "Busy", value: p.BusyCount }, + { label: "Total Invocations", value: p.TotalInvocations?.toLocaleString() ?? 0 }, + { label: "Total Busy Time", value: formatDuration(p.TotalBusyMs) }, + { label: "Avg Utilization", value: `${p.AvgUtilizationPct ?? 0}%` }, + { label: "Avg Duration", value: formatDuration(p.AvgDurationMs) }, + { label: "Total Faults", value: p.TotalFaults ?? 0 }, + ]; + }, [snapshot]); + + const bgPoolItems = useMemo(() => { + if (!snapshot?.BgPool) return []; + const p = snapshot.BgPool; + return [ + { label: "Pool Size", value: p.PoolSize }, + { label: "Available", value: p.Available }, + { label: "Busy", value: p.BusyCount }, + { label: "Total Invocations", value: p.TotalInvocations?.toLocaleString() ?? 0 }, + { label: "Total Busy Time", value: formatDuration(p.TotalBusyMs) }, + { label: "Avg Utilization", value: `${p.AvgUtilizationPct ?? 0}%` }, + { label: "Avg Duration", value: formatDuration(p.AvgDurationMs) }, + { label: "Total Faults", value: p.TotalFaults ?? 0 }, + ]; + }, [snapshot]); + + const limiterItems = useMemo(() => { + if (!snapshot?.Limiter) return []; + const l = snapshot.Limiter; + return [ + { label: "Base Concurrency", value: l.BaseConcurrency }, + { label: "Ceiling Concurrency", value: l.CeilingConcurrency }, + { label: "Current Max", value: l.CurrentMax }, + { label: "Active", value: l.Active }, + { label: "Waiting", value: l.Waiting }, + { + label: "HTTP Throttled", + value: l.IsHttpThrottled ? "Yes" : "No", + }, + ]; + }, [snapshot]); + + const jobItems = useMemo(() => { + if (!snapshot?.Jobs) return []; + const j = snapshot.Jobs; + return [ + { label: "Running", value: j.Running }, + { label: "Queued", value: j.Queued }, + { label: "Completed", value: j.Completed?.toLocaleString() ?? 0 }, + { label: "Failed", value: j.Failed }, + { label: "Total Processed", value: j.TotalProcessed?.toLocaleString() ?? 0 }, + { label: "Max Concurrency", value: j.MaxConcurrency }, + { label: "Active Concurrency", value: j.ActiveConcurrency }, + ]; + }, [snapshot]); + + const jobSimpleColumns = ["Name", "RunName", "Priority", "Status", "WaitSeconds", "DurationSeconds"]; + + const jobActions = useMemo( + () => [ + { + label: "Cancel Job", + icon: , + color: "error.main", + noConfirm: true, + customFunction: (row) => { + jobAction.mutate({ + url: "/api/ListWorkerHealth", + data: { Action: "CancelJob", JobId: row.Id }, + }); + }, + condition: (row) => row.Status === "Queued", + }, + { + label: "Change Priority", + icon: , + fields: [ + { + type: "textField", + name: "Priority", + label: "New Priority (0 = highest)", + }, + ], + url: "/api/ListWorkerHealth", + data: { Action: "ChangePriority" }, + dataFunction: (row, formData) => ({ + Action: "ChangePriority", + JobId: row.Id, + Priority: parseInt(formData.Priority, 10), + }), + confirmText: "Change", + condition: (row) => row.Status === "Queued", + relatedQueryKeys: ["WorkerHealthJobs", "WorkerHealth"], + }, + { + label: "Cancel Run", + icon: , + color: "error.main", + noConfirm: true, + customFunction: (row) => { + if (row.RunName) { + jobAction.mutate({ + url: "/api/ListWorkerHealth", + data: { Action: "CancelRun", RunName: row.RunName }, + }); + } + }, + condition: (row) => row.Status === "Queued" && row.RunName, + }, + { + label: "Delete", + icon: , + noConfirm: true, + customFunction: (row) => { + jobAction.mutate({ + url: "/api/ListWorkerHealth", + data: { Action: "DeleteJob", JobId: row.Id }, + }); + }, + condition: (row) => row.Status !== "Queued" && row.Status !== "Running", + }, + ], + [jobAction] + ); + + const jobFilters = useMemo( + () => [ + { + filterName: "Queued", + value: [{ id: "Status", value: "Queued" }], + }, + { + filterName: "Running", + value: [{ id: "Status", value: "Running" }], + }, + { + filterName: "Failed", + value: [{ id: "Status", value: "Failed" }], + }, + ], + [] + ); + + return ( + <> + + Worker Health | CIPP + + + + + + Worker Health + + {healthQuery.isFetching && } + {snapshot && ( + + Uptime: {formatUptime(snapshot.UptimeSeconds)} | Auto-refreshing every 5s + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + } + color="warning" + onClick={() => + jobAction.mutate({ + url: "/api/ListWorkerHealth", + data: { Action: "PurgeCompleted" }, + }) + } + > + Purge Completed + + } + /> + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; From 7a85827ef1072955a48cb1d48c3ce2aafe3ab88d Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 15 May 2026 16:34:51 +0200 Subject: [PATCH 065/164] feat(users): add bulk update contact and UPN fields Fixes #6015 Fixes #6013 --- .../administration/users/patch-wizard.jsx | 81 ++++++++++++++++++- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/src/pages/identity/administration/users/patch-wizard.jsx b/src/pages/identity/administration/users/patch-wizard.jsx index 1168f12f593e..67e1d08041a9 100644 --- a/src/pages/identity/administration/users/patch-wizard.jsx +++ b/src/pages/identity/administration/users/patch-wizard.jsx @@ -21,11 +21,17 @@ import { CippWizardStepButtons } from '../../../../components/CippWizard/CippWiz import { ApiPostCall, ApiGetCall } from '../../../../api/ApiCall' import { CippApiResults } from '../../../../components/CippComponents/CippApiResults' import { CippDataTable } from '../../../../components/CippTable/CippDataTable' +import { CippFormDomainSelector } from '../../../../components/CippComponents/CippFormDomainSelector' import { CippFormUserSelector } from '../../../../components/CippComponents/CippFormUserSelector' import { Delete } from '@mui/icons-material' // User properties that can be patched const PATCHABLE_PROPERTIES = [ + { + property: 'businessPhones', + label: 'Business Phone', + type: 'string', + }, { property: 'city', label: 'City', @@ -51,6 +57,11 @@ const PATCHABLE_PROPERTIES = [ label: 'Employee Type', type: 'string', }, + { + property: 'faxNumber', + label: 'Fax Number', + type: 'string', + }, { property: 'jobTitle', label: 'Job Title', @@ -61,6 +72,11 @@ const PATCHABLE_PROPERTIES = [ label: 'Manager', type: 'userSelector', }, + { + property: 'mobilePhone', + label: 'Mobile Phone', + type: 'string', + }, { property: 'officeLocation', label: 'Office Location', @@ -106,6 +122,11 @@ const PATCHABLE_PROPERTIES = [ label: 'Usage Location', type: 'string', }, + { + property: 'userPrincipalName', + label: 'UPN Domain Suffix', + type: 'domainPicker', + }, ] // Step 1: Display users to be updated @@ -195,12 +216,15 @@ const PropertySelectionStep = (props) => { const tenantDomains = [...new Set(users?.map((user) => user.Tenant || user.tenantFilter).filter(Boolean))] || [] const firstTenantDomain = tenantDomains[0] + const isSingleTenant = tenantDomains.length <= 1 const hasManagerSelected = selectedProperties.includes('manager') const hasSponsorSelected = selectedProperties.includes('sponsor') const hasRelationshipSelected = hasManagerSelected || hasSponsorSelected + const hasUPNSelected = selectedProperties.includes('userPrincipalName') + const hasTenantScopedSelectorSelected = hasRelationshipSelected || hasUPNSelected useEffect(() => { - if (!hasRelationshipSelected || !firstTenantDomain) { + if (!hasTenantScopedSelectorSelected || !firstTenantDomain) { return } @@ -208,7 +232,7 @@ const PropertySelectionStep = (props) => { if (currentTenantFilter?.value !== firstTenantDomain) { formControl.setValue('tenantFilter', { value: firstTenantDomain }) } - }, [firstTenantDomain, formControl, hasRelationshipSelected]) + }, [firstTenantDomain, formControl, hasTenantScopedSelectorSelected]) // Fetch custom data mappings for all tenants const customDataMappings = ApiGetCall({ @@ -242,8 +266,13 @@ const PropertySelectionStep = (props) => { // Combine standard properties with custom data properties const allProperties = useMemo(() => { - return [...PATCHABLE_PROPERTIES, ...customDataProperties] - }, [customDataProperties]) + return [ + ...PATCHABLE_PROPERTIES.filter( + (property) => isSingleTenant || property.property !== 'userPrincipalName' + ), + ...customDataProperties, + ] + }, [customDataProperties, isSingleTenant]) // Register form fields formControl.register('selectedProperties', { required: true }) @@ -290,6 +319,24 @@ const PropertySelectionStep = (props) => { ) } + if (property?.type === 'domainPicker') { + return ( + + + Changes the domain after @ only. Users will be logged out and must sign in with the new + UPN. + + + + ) + } + // Default to text input for string types with consistent styling return ( { Loading custom data mappings... )} + {!isSingleTenant && ( + + UPN domain suffix changes are only available when all selected users are from the same + tenant. + + )} { return } + if (propName === 'businessPhones') { + userData[propName] = [propertyValue] + return + } + + if (propName === 'userPrincipalName') { + const selectedDomain = propertyValue?.value || propertyValue?.label || propertyValue + const currentUPN = user.userPrincipalName || '' + const upnPrefix = currentUPN.includes('@') ? currentUPN.split('@')[0] : currentUPN + + if (selectedDomain && upnPrefix) { + userData[propName] = `${upnPrefix}@${selectedDomain}` + } + + return + } + // Handle dot notation properties (e.g., "extension_abc123.customField") if (propName.includes('.')) { const parts = propName.split('.') @@ -581,6 +651,9 @@ const ConfirmationStep = (props) => { if (propName === 'manager' || propName === 'sponsor') { displayValue = value?.label || value?.value || 'Not set' + } else if (propName === 'userPrincipalName') { + const selectedDomain = value?.label || value?.value || value + displayValue = selectedDomain ? `Change domain to: ${selectedDomain}` : 'Not set' } else if (property?.type === 'boolean') { displayValue = value ? 'Yes' : 'No' } From 8232e5c11b26c897151d6daaaaa74f8288f52110 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 15 May 2026 20:30:01 +0200 Subject: [PATCH 066/164] feat(standards): add intuneRestrictUserDeviceJoin entry Frontend pair for the new backend standard covering azureADJoin. Co-Authored-By: Claude Opus 4.7 --- src/data/standards.json | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index 4c61c04cf250..410739a2ebb5 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -4455,6 +4455,29 @@ "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", "recommendedBy": [] }, + { + "name": "standards.intuneRestrictUserDeviceJoin", + "cat": "Entra (AAD) Standards", + "tag": [], + "appliesToTest": [], + "helpText": "Controls whether users can join devices to Entra.", + "docsDescription": "Configures whether users can join devices to Entra. When disabled, users are unable to Entra-join devices, which prevents them from creating new Entra-joined (cloud-managed) device identities.", + "executiveText": "Controls whether employees can join their devices to the corporate Entra directory. Disabling user device join prevents unauthorized or unmanaged devices from becoming corporate-managed identities, enhancing overall security posture.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.intuneRestrictUserDeviceJoin.disableUserDeviceJoin", + "label": "Disable users from joining devices", + "defaultValue": true + } + ], + "label": "Configure user restriction for Entra device join", + "impact": "High Impact", + "impactColour": "warning", + "addedDate": "2026-05-15", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "recommendedBy": [] + }, { "name": "standards.intuneRequireMFA", "cat": "Intune Standards", From f768330cd45a2b2e1f6a7cae857b7a5e2ec7d999 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 15 May 2026 20:30:31 +0200 Subject: [PATCH 067/164] fix(standards): move CIS 5.1.4.1 and SMB1001 (2.8) tags to join standard Both controls describe restricting device join, not registration. Without the move, BPA would report them green whenever registration was disabled, even with join wide open. Co-Authored-By: Claude Opus 4.7 --- src/data/standards.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index 410739a2ebb5..6db99775997c 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -4432,11 +4432,8 @@ { "name": "standards.intuneRestrictUserDeviceRegistration", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.1)", "SMB1001 (2.8)"], - "appliesToTest": [ - "CIS_5_1_4_1", - "SMB1001_2_8" - ], + "tag": [], + "appliesToTest": [], "helpText": "Controls whether users can register devices with Entra.", "docsDescription": "Configures whether users can register devices with Entra. When disabled, users are unable to register devices with Entra.", "executiveText": "Controls whether employees can register their devices for corporate access. Disabling user device registration prevents unauthorized or unmanaged devices from connecting to company resources, enhancing overall security posture.", @@ -4458,8 +4455,11 @@ { "name": "standards.intuneRestrictUserDeviceJoin", "cat": "Entra (AAD) Standards", - "tag": [], - "appliesToTest": [], + "tag": ["CIS M365 6.0.1 (5.1.4.1)", "SMB1001 (2.8)"], + "appliesToTest": [ + "CIS_5_1_4_1", + "SMB1001_2_8" + ], "helpText": "Controls whether users can join devices to Entra.", "docsDescription": "Configures whether users can join devices to Entra. When disabled, users are unable to Entra-join devices, which prevents them from creating new Entra-joined (cloud-managed) device identities.", "executiveText": "Controls whether employees can join their devices to the corporate Entra directory. Disabling user device join prevents unauthorized or unmanaged devices from becoming corporate-managed identities, enhancing overall security posture.", From 9d5ce40275098a4a442d247fe42541958d08ba88 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 18 May 2026 07:27:51 -0400 Subject: [PATCH 068/164] Org auto expanding archive property usage --- src/components/CippCards/CippExchangeInfoCard.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/CippCards/CippExchangeInfoCard.jsx b/src/components/CippCards/CippExchangeInfoCard.jsx index 6a00f53c0248..8bb2737f76c3 100644 --- a/src/components/CippCards/CippExchangeInfoCard.jsx +++ b/src/components/CippCards/CippExchangeInfoCard.jsx @@ -247,7 +247,9 @@ export const CippExchangeInfoCard = (props) => { <> - Auto Expanding Archive: + {exchangeData?.AutoExpandingArchiveScope === 'Organization' + ? 'Auto Expanding Archive: (org)' + : 'Auto Expanding Archive:'} {getCippFormatting( From 6db7e7760fc02807df809897fcda40b48a8b365b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 18 May 2026 14:37:01 -0400 Subject: [PATCH 069/164] Delete .claude directory Signed-off-by: Zacgoose <107489668+Zacgoose@users.noreply.github.com> --- .claude/worktrees/blissful-golick-d405ab | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .claude/worktrees/blissful-golick-d405ab diff --git a/.claude/worktrees/blissful-golick-d405ab b/.claude/worktrees/blissful-golick-d405ab deleted file mode 160000 index 0710355e2ada..000000000000 --- a/.claude/worktrees/blissful-golick-d405ab +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0710355e2adac37fffe4c7eef48d6f2c3a04993d From 1e7aef11995feb42e9872ec4aefac39fc7ba67c5 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 19 May 2026 08:19:29 -0400 Subject: [PATCH 070/164] Update alerts.json --- src/data/alerts.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/alerts.json b/src/data/alerts.json index 9bce965852ab..ac8a28fa6cc0 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -125,7 +125,7 @@ "inputType": "textField", "inputLabel": "Enter quota percentage", "inputName": "QuotaUsedQuota", - "recommendedRunInterval": "4h" + "recommendedRunInterval": "1d" }, { "name": "SharePointQuota", @@ -134,7 +134,7 @@ "inputType": "textField", "inputLabel": "Enter quota percentage", "inputName": "SharePointQuota", - "recommendedRunInterval": "4h" + "recommendedRunInterval": "1d" }, { "name": "OneDriveQuota", @@ -143,7 +143,7 @@ "inputType": "textField", "inputLabel": "Enter quota percentage (default: 90)", "inputName": "OneDriveQuota", - "recommendedRunInterval": "4h" + "recommendedRunInterval": "1d" }, { "name": "ExpiringLicenses", From fc246a54ee6c1720f9d442f4cf22568b53add85d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 19 May 2026 10:02:02 -0400 Subject: [PATCH 071/164] update default value for standard --- src/data/standards.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/standards.json b/src/data/standards.json index 4c61c04cf250..16a6406db1fa 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -4100,7 +4100,7 @@ "type": "number", "name": "standards.IntuneComplianceSettings.deviceComplianceCheckinThresholdDays", "label": "Compliance status validity period (days)", - "defaultValue": 130, + "defaultValue": 120, "validators": { "min": { "value": 1, "message": "Minimum value is 1" }, "max": { "value": 120, "message": "Maximum value is 120" } From 766a3c52814dcc09d9170c780b4caa5e59d7a343 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 20 May 2026 22:10:11 -0400 Subject: [PATCH 072/164] feat: add in missing options for Windows Hello standard Fixes #6034 --- src/data/standards.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index 16a6406db1fa..bc21aa38b6c3 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -4358,6 +4358,28 @@ "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.remotePassportEnabled", "label": "Allow phone sign-in", "default": true + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.enhancedSignInSecurity", + "label": "Enable enhanced sign-in security", + "multiple": false, + "options": [ + { "label": "Not configured", "value": "0" }, + { "label": "Enabled on capable hardware", "value": "1" }, + { "label": "Disabled on all systems", "value": "2" } + ] + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.securityKeyForSignIn", + "label": "Use security keys for sign-in", + "multiple": false, + "options": [ + { "label": "Not configured", "value": "notConfigured" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ] } ], "label": "Windows Hello for Business enrollment configuration", From 5b5302ca907b971e86c5ed5fd8c1f56491f796c8 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 20 May 2026 23:18:38 -0400 Subject: [PATCH 073/164] feat(standards): add DLP via DCS OWA standard --- src/data/standards.json | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index 16a6406db1fa..eb49a0e5b91d 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -2707,6 +2707,40 @@ "EXCHANGE_LITE" ] }, + { + "name": "standards.DlpViaDcsEnabled", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Sets whether Outlook on the web uses Data Classification Services for DLP evaluation. See [Microsoft's policy tip reference](https://learn.microsoft.com/en-us/purview/dlp-ol365-win32-policy-tips#sensitive-information-types-that-support-policy-tips-for-outlook-perpetual-users).", + "docsDescription": "Configures whether Outlook on the web uses Data Classification Services (DCS)-based Data Loss Prevention (DLP) policy evaluation instead of Exchange-based evaluation. Review DLP policies before enabling this setting, as some legacy Exchange-based predicates are not supported with DCS-based evaluation. See [Microsoft's policy tip reference](https://learn.microsoft.com/en-us/purview/dlp-ol365-win32-policy-tips#sensitive-information-types-that-support-policy-tips-for-outlook-perpetual-users).", + "executiveText": "Improves how Outlook on the web applies Data Loss Prevention policies, giving users clearer guidance when sensitive information may be shared and helping reduce accidental data exposure.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.DlpViaDcsEnabled.state", + "options": [ + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } + ] + } + ], + "label": "Set OWA DLP evaluation via DCS", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-20", + "powershellEquivalent": "Set-OrganizationConfig -DlpViaDcsEnabled", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, { "name": "standards.UserSubmissions", "cat": "Exchange Standards", From 131927b943aebebd6844ba986fbcf30fb21ac8bc Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 22 May 2026 12:51:00 -0400 Subject: [PATCH 074/164] Stats --- src/components/PrivateRoute.js | 33 +- src/layouts/index.js | 4 +- src/pages/cipp/advanced/worker-health.js | 785 +++++++++++++++++++---- 3 files changed, 674 insertions(+), 148 deletions(-) diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index 5b067cf4e7c3..15b438b2c608 100644 --- a/src/components/PrivateRoute.js +++ b/src/components/PrivateRoute.js @@ -2,8 +2,11 @@ import { ApiGetCall } from "../api/ApiCall.jsx"; import UnauthenticatedPage from "../pages/unauthenticated.js"; import LoadingPage from "../pages/loading.js"; import ApiOfflinePage from "../pages/api-offline.js"; +import { useState, useEffect } from "react"; export const PrivateRoute = ({ children, routeType }) => { + const [unauthLatched, setUnauthLatched] = useState(false); + const session = ApiGetCall({ url: "/.auth/me", queryKey: "authmeswa", @@ -11,13 +14,34 @@ export const PrivateRoute = ({ children, routeType }) => { staleTime: 120000, // 2 minutes }); + // Latch the unauthenticated state so refetches from child components + // don't flip us back to loading. Clear the latch when session succeeds (after login). + useEffect(() => { + if ( + !session.isLoading && + !session.isFetching && + (session.isError || + null === session?.data?.clientPrincipal || + session?.data === undefined) + ) { + setUnauthLatched(true); + } else if (session.isSuccess && session.data?.clientPrincipal) { + setUnauthLatched(false); + } + }, [session.isLoading, session.isFetching, session.isError, session.isSuccess, session.data]); + const apiRoles = ApiGetCall({ url: "/api/me", queryKey: "authmecipp", - retry: 2, // Reduced retry count to show offline message sooner - waiting: !session.isSuccess || session.data?.clientPrincipal === null, + retry: 2, + waiting: session.isSuccess && session.data?.clientPrincipal !== null, }); + // If latched as unauthenticated, always show unauthenticated page + if (unauthLatched) { + return ; + } + // Check if the session is still loading before determining authentication status if ( session.isLoading || @@ -38,11 +62,6 @@ export const PrivateRoute = ({ children, routeType }) => { return ; } - // if not logged into swa - if (null === session?.data?.clientPrincipal || session?.data === undefined) { - return ; - } - let roles = null; if ( diff --git a/src/layouts/index.js b/src/layouts/index.js index d00dc338a82d..f69628a3c706 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -35,7 +35,7 @@ const OnboardingWizardPage = dynamic( { ssr: false } ) -const SIDE_NAV_WIDTH = 270 +const SIDE_NAV_WIDTH = 290 const SIDE_NAV_PINNED_WIDTH = 50 const TOP_NAV_HEIGHT = 50 @@ -111,7 +111,7 @@ export const Layout = (props) => { const currentRole = ApiGetCall({ url: '/api/me', queryKey: 'authmecipp', - waiting: !swaStatus.isSuccess || swaStatus.data?.clientPrincipal === null, + waiting: swaStatus.isSuccess && swaStatus.data?.clientPrincipal !== null, }) const featureFlags = ApiGetCall({ diff --git a/src/pages/cipp/advanced/worker-health.js b/src/pages/cipp/advanced/worker-health.js index fd23de33c031..ccc19a335570 100644 --- a/src/pages/cipp/advanced/worker-health.js +++ b/src/pages/cipp/advanced/worker-health.js @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import Head from "next/head"; import { Box, @@ -9,6 +9,7 @@ import { Chip, CircularProgress, Container, + IconButton, LinearProgress, Stack, Table, @@ -17,6 +18,9 @@ import { TableContainer, TableHead, TableRow, + ToggleButton, + ToggleButtonGroup, + Tooltip, Typography, } from "@mui/material"; import { @@ -30,11 +34,33 @@ import { Delete, LowPriority, DeleteSweep, + Timeline, + RocketLaunch, + Pause, + FileDownload, + FileUpload, + Refresh, + Close, } from "@mui/icons-material"; import { Grid } from "@mui/system"; +import { useTheme } from "@mui/material/styles"; +import { useQueryClient } from "@tanstack/react-query"; +import { + AreaChart, + Area, + LineChart, + Line, + BarChart, + Bar, + CartesianGrid, + XAxis, + YAxis, + ResponsiveContainer, + Tooltip as RechartsTooltip, + Legend, +} from "recharts"; import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { CippInfoBar } from "../../../components/CippCards/CippInfoBar"; -import { CippPropertyListCard } from "../../../components/CippCards/CippPropertyListCard"; import { CippDataTable } from "../../../components/CippTable/CippDataTable"; import { ApiGetCall, ApiPostCall } from "../../../api/ApiCall"; @@ -142,19 +168,376 @@ const WorkerTable = ({ workers, title }) => { ); }; +const TIME_RANGES = [ + { label: "1h", minutes: 60 }, + { label: "6h", minutes: 360 }, + { label: "24h", minutes: 1440 }, + { label: "3d", minutes: 4320 }, + { label: "7d", minutes: 10080 }, +]; + +const formatChartTime = (timestamp, rangeMinutes) => { + const d = new Date(timestamp); + if (rangeMinutes <= 1440) { + return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); + } + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const STARTUP_PHASES = [ + { key: "BaseWorkerMs", label: "Base Worker", fkey: "BaseFunctionCount", color: "#7c4dff" }, + { key: "WarmupMs", label: "Warmup", fkey: null, color: "#ffc107" }, + { key: "HttpReadyMs", label: "HTTP Ready", fkey: "HttpFunctionCount", color: "#00c853" }, + { key: "HttpPoolFullMs", label: "HTTP Pool Full", fkey: null, color: "#69f0ae" }, + { key: "BgReadyMs", label: "BG Ready", fkey: "BgFunctionCount", color: "#29b6f6" }, + { key: "FullyReadyMs", label: "Fully Ready", fkey: null, color: "#66bb6a" }, +]; + +const StartupTimingBar = ({ startup }) => { + if (!startup) return null; + + // Build segments as incremental durations between phases + const phases = STARTUP_PHASES.filter((p) => startup[p.key] > 0); + const totalMs = startup.FullyReadyMs || Math.max(...phases.map((p) => startup[p.key]), 1); + + // Compute incremental segments (each phase = cumulative time to that point) + const segments = phases.map((phase, i) => { + const cumMs = startup[phase.key]; + const prevMs = i > 0 ? startup[phases[i - 1].key] : 0; + const deltaMs = Math.max(cumMs - prevMs, 0); + return { + ...phase, + cumMs, + deltaMs, + pct: totalMs > 0 ? (deltaMs / totalMs) * 100 : 0, + functions: phase.fkey ? startup[phase.fkey] : null, + }; + }); + + return ( + + } + subheader={`${startup.ReadinessMode} / ${startup.WarmupMode} — ${startup.CpuCount} CPUs, ${startup.HttpPoolSize}H + ${startup.BgPoolSize}BG — Total: ${formatDuration(totalMs)}`} + subheaderTypographyProps={{ variant: "caption" }} + sx={{ pb: 0 }} + /> + + {/* Single horizontal stacked bar */} + + {segments.map((seg) => ( + + + {seg.label} + + + {formatDuration(seg.deltaMs)} (cumulative: {formatDuration(seg.cumMs)}) + + {seg.functions != null && ( + + {seg.functions} functions loaded + + )} + + } + > + 8 ? 0 : 4, + cursor: "pointer", + transition: "filter 0.15s", + "&:hover": { filter: "brightness(1.2)" }, + }} + > + {seg.pct > 12 && ( + + {formatDuration(seg.deltaMs)} + + )} + + + ))} + + {/* Legend */} + + {segments.map((seg) => ( + + + + {seg.label} + {seg.functions != null && ` (${seg.functions})`} + + + ))} + + Modules: {startup.SharedModuleCount} shared + {startup.HttpOnlyModuleCount > 0 && `, ${startup.HttpOnlyModuleCount} HTTP`} + {startup.BgOnlyModuleCount > 0 && `, ${startup.BgOnlyModuleCount} BG`} + + + + + ); +}; + +const CompactStatsRow = ({ snapshot }) => { + if (!snapshot) return null; + + const http = snapshot.HttpPool || {}; + const bg = snapshot.BgPool || {}; + const jobs = snapshot.Jobs || {}; + const limiter = snapshot.Limiter || {}; + + const sections = [ + { + label: "HTTP Pool", + color: "primary", + stats: [ + { k: "Size", v: http.PoolSize ?? 0 }, + { k: "Busy", v: http.BusyCount ?? 0, w: http.BusyCount >= http.PoolSize }, + { k: "Invocations", v: http.TotalInvocations?.toLocaleString() ?? 0 }, + { k: "Util", v: `${http.AvgUtilizationPct ?? 0}%`, w: http.AvgUtilizationPct > 80 }, + { k: "Avg", v: formatDuration(http.AvgDurationMs) }, + { k: "Faults", v: http.TotalFaults ?? 0, w: http.TotalFaults > 0 }, + ], + }, + { + label: "BG Pool", + color: "warning", + stats: [ + { k: "Size", v: bg.PoolSize ?? 0 }, + { k: "Busy", v: bg.BusyCount ?? 0, w: bg.BusyCount >= bg.PoolSize }, + { k: "Invocations", v: bg.TotalInvocations?.toLocaleString() ?? 0 }, + { k: "Util", v: `${bg.AvgUtilizationPct ?? 0}%`, w: bg.AvgUtilizationPct > 80 }, + { k: "Avg", v: formatDuration(bg.AvgDurationMs) }, + { k: "Faults", v: bg.TotalFaults ?? 0, w: bg.TotalFaults > 0 }, + ], + }, + { + label: "Jobs", + color: "info", + stats: [ + { k: "Running", v: jobs.Running ?? 0 }, + { k: "Queued", v: jobs.Queued ?? 0, w: jobs.Queued > 10 }, + { k: "Done", v: jobs.Completed?.toLocaleString() ?? 0 }, + { k: "Failed", v: jobs.Failed ?? 0, w: jobs.Failed > 0 }, + ], + }, + { + label: "Limiter", + color: "default", + stats: [ + { k: "Active", v: `${limiter.Active ?? 0} / ${limiter.CurrentMax ?? 0}` }, + { k: "Waiting", v: limiter.Waiting ?? 0 }, + ...(limiter.IsHttpThrottled ? [{ k: "Status", v: "Throttled", w: true }] : []), + ], + }, + ]; + + return ( + + + + + + {sections.map((sec) => ( + + + + + {sec.stats.map((s) => ( + + + {s.k} + + + {s.v} + + + ))} + {/* Pad empty cells so columns stay aligned */} + {Array.from({ length: Math.max(0, 6 - sec.stats.length) }).map((_, i) => ( + + ))} + + ))} + +
+
+
+
+ ); +}; + +const HistoryChart = ({ data, rangeMinutes, title, icon, children }) => { + const theme = useTheme(); + + if (!data || data.length === 0) { + return ( + + + + + + No historical data available yet — data collection starts after 60 seconds + + + + + ); + } + + return ( + + + + + + {children(data, theme)} + + + + + ); +}; + const Page = () => { + const theme = useTheme(); + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + const [historyRange, setHistoryRange] = useState(60); + const [paused, setPaused] = useState(false); + const [importedData, setImportedData] = useState(null); + + const isImported = importedData !== null; + const effectivePaused = paused || isImported; + const healthQuery = ApiGetCall({ url: "/api/ListWorkerHealth", data: { Action: "Snapshot" }, queryKey: "WorkerHealth", - refetchInterval: 5000, + refetchInterval: effectivePaused ? false : 5000, + }); + + const startupQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "Startup" }, + queryKey: "WorkerStartup", + }); + + const historyQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "History", Minutes: String(historyRange), MaxPoints: "500" }, + queryKey: `WorkerHistory-${historyRange}`, + refetchInterval: effectivePaused ? false : 60000, }); const jobAction = ApiPostCall({ relatedQueryKeys: ["WorkerHealthJobs", "WorkerHealth"], }); - const snapshot = healthQuery.data?.Results; + // Resolve data: imported overrides live + const snapshot = isImported ? importedData.snapshot : healthQuery.data?.Results; + const startupInfo = isImported ? importedData.startup : startupQuery.data?.Results; + const importedJobs = useMemo(() => { + if (!isImported || !importedData.jobs) return null; + // Handle both array and { Results: [...] } shapes from query cache + if (Array.isArray(importedData.jobs)) return importedData.jobs; + if (Array.isArray(importedData.jobs?.Results)) return importedData.jobs.Results; + if (Array.isArray(importedData.jobs?.data?.Results)) return importedData.jobs.data.Results; + if (Array.isArray(importedData.jobs?.data)) return importedData.jobs.data; + return []; + }, [isImported, importedData]); + + const historyData = useMemo(() => { + const raw = isImported + ? importedData.history?.Data ?? importedData.history + : historyQuery.data?.Results?.Data; + if (!raw || !Array.isArray(raw)) return []; + return raw.map((p) => ({ + ...p, + time: formatChartTime(p.TimestampUtc, isImported ? importedData.historyRange ?? 60 : historyRange), + })); + }, [historyQuery.data, historyRange, importedData, isImported]); + + // ── Export ── + const handleExport = useCallback(() => { + const payload = { + exportedAt: new Date().toISOString(), + historyRange, + snapshot: healthQuery.data?.Results ?? null, + startup: startupQuery.data?.Results ?? null, + history: historyQuery.data?.Results ?? null, + jobs: null, + }; + // Try to grab current job data from query cache + // CippDataTable may store the key with extra params, so search by prefix + const allQueries = queryClient.getQueriesData({ queryKey: ["WorkerHealthJobs"] }); + for (const [, data] of allQueries) { + if (data) { + const rows = data?.Results ?? data?.data?.Results ?? data; + if (Array.isArray(rows)) { + payload.jobs = rows; + break; + } + } + } + + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `worker-health-${new Date().toISOString().slice(0, 16).replace(/:/g, "")}.json`; + a.click(); + URL.revokeObjectURL(url); + }, [healthQuery.data, startupQuery.data, historyQuery.data, historyRange, queryClient]); + + // ── Import ── + const handleImport = useCallback((event) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target.result); + setImportedData(data); + setPaused(true); + } catch { + // invalid JSON — ignore + } + }; + reader.readAsText(file); + // Reset input so same file can be re-imported + event.target.value = ""; + }, []); + + const handleClearImport = useCallback(() => { + setImportedData(null); + setPaused(false); + }, []); + + const handleRefreshHistory = useCallback(() => { + queryClient.invalidateQueries({ queryKey: [`WorkerHistory-${historyRange}`] }); + }, [queryClient, historyRange]); const infoBarData = useMemo(() => { if (!snapshot) return []; @@ -193,66 +576,6 @@ const Page = () => { ]; }, [snapshot]); - const httpPoolItems = useMemo(() => { - if (!snapshot?.HttpPool) return []; - const p = snapshot.HttpPool; - return [ - { label: "Pool Size", value: p.PoolSize }, - { label: "Available", value: p.Available }, - { label: "Busy", value: p.BusyCount }, - { label: "Total Invocations", value: p.TotalInvocations?.toLocaleString() ?? 0 }, - { label: "Total Busy Time", value: formatDuration(p.TotalBusyMs) }, - { label: "Avg Utilization", value: `${p.AvgUtilizationPct ?? 0}%` }, - { label: "Avg Duration", value: formatDuration(p.AvgDurationMs) }, - { label: "Total Faults", value: p.TotalFaults ?? 0 }, - ]; - }, [snapshot]); - - const bgPoolItems = useMemo(() => { - if (!snapshot?.BgPool) return []; - const p = snapshot.BgPool; - return [ - { label: "Pool Size", value: p.PoolSize }, - { label: "Available", value: p.Available }, - { label: "Busy", value: p.BusyCount }, - { label: "Total Invocations", value: p.TotalInvocations?.toLocaleString() ?? 0 }, - { label: "Total Busy Time", value: formatDuration(p.TotalBusyMs) }, - { label: "Avg Utilization", value: `${p.AvgUtilizationPct ?? 0}%` }, - { label: "Avg Duration", value: formatDuration(p.AvgDurationMs) }, - { label: "Total Faults", value: p.TotalFaults ?? 0 }, - ]; - }, [snapshot]); - - const limiterItems = useMemo(() => { - if (!snapshot?.Limiter) return []; - const l = snapshot.Limiter; - return [ - { label: "Base Concurrency", value: l.BaseConcurrency }, - { label: "Ceiling Concurrency", value: l.CeilingConcurrency }, - { label: "Current Max", value: l.CurrentMax }, - { label: "Active", value: l.Active }, - { label: "Waiting", value: l.Waiting }, - { - label: "HTTP Throttled", - value: l.IsHttpThrottled ? "Yes" : "No", - }, - ]; - }, [snapshot]); - - const jobItems = useMemo(() => { - if (!snapshot?.Jobs) return []; - const j = snapshot.Jobs; - return [ - { label: "Running", value: j.Running }, - { label: "Queued", value: j.Queued }, - { label: "Completed", value: j.Completed?.toLocaleString() ?? 0 }, - { label: "Failed", value: j.Failed }, - { label: "Total Processed", value: j.TotalProcessed?.toLocaleString() ?? 0 }, - { label: "Max Concurrency", value: j.MaxConcurrency }, - { label: "Active Concurrency", value: j.ActiveConcurrency }, - ]; - }, [snapshot]); - const jobSimpleColumns = ["Name", "RunName", "Priority", "Status", "WaitSeconds", "DurationSeconds"]; const jobActions = useMemo( @@ -324,18 +647,9 @@ const Page = () => { const jobFilters = useMemo( () => [ - { - filterName: "Queued", - value: [{ id: "Status", value: "Queued" }], - }, - { - filterName: "Running", - value: [{ id: "Status", value: "Running" }], - }, - { - filterName: "Failed", - value: [{ id: "Status", value: "Failed" }], - }, + { filterName: "Queued", value: [{ id: "Status", value: "Queued" }] }, + { filterName: "Running", value: [{ id: "Status", value: "Running" }] }, + { filterName: "Failed", value: [{ id: "Status", value: "Failed" }] }, ], [] ); @@ -347,89 +661,282 @@ const Page = () => { - + + {/* ── Header toolbar ── */} Worker Health - {healthQuery.isFetching && } - {snapshot && ( - - Uptime: {formatUptime(snapshot.UptimeSeconds)} | Auto-refreshing every 5s + {isImported && ( + } + /> + )} + {!isImported && healthQuery.isFetching && } + {!isImported && snapshot && ( + + Uptime: {formatUptime(snapshot.UptimeSeconds)} )} + + setPaused((p) => !p)} + color={effectivePaused ? "warning" : "default"} + disabled={isImported} + > + {effectivePaused ? : } + + + + + + + + + fileInputRef.current?.click()}> + + + + + {/* ── KPI bar ── */} + {/* ── Compact pool / jobs / limiter stats ── */} + + + {/* ── Worker tables ── */} + + + + {/* ── Job Queue ── */} + {isImported && importedJobs ? ( + + + + {importedJobs.length === 0 ? ( + + + No job data was captured in this export + + + ) : ( + + + + + {jobSimpleColumns.map((col) => ( + {col} + ))} + + + + {importedJobs.slice(0, 200).map((row, i) => ( + + {jobSimpleColumns.map((col) => ( + + {row[col] != null ? String(row[col]) : "—"} + + ))} + + ))} + +
+
+ )} +
+
+ ) : ( + } + color="warning" + onClick={() => + jobAction.mutate({ + url: "/api/ListWorkerHealth", + data: { Action: "PurgeCompleted" }, + }) + } + > + Purge Completed + + } + /> + )} + + {/* ── Historical Trends header with controls ── */} + + } + action={ + + {!isImported && ( + + + + + + )} + val !== null && setHistoryRange(val)} + size="small" + disabled={isImported} + > + {TIME_RANGES.map((r) => ( + + {r.label} + + ))} + + + } + /> + + - - + + } + > + {(data, t) => ( + + + + + + + + + + )} + - - + + } + > + {(data, t) => ( + + + + + + + + + + )} + - - + + } + > + {(data, t) => ( + + + + + + + + + + )} + - - + + } + > + {(data, t) => ( + + + + + + + + + + + + + )} + - - - - } - color="warning" - onClick={() => - jobAction.mutate({ - url: "/api/ListWorkerHealth", - data: { Action: "PurgeCompleted" }, - }) - } - > - Purge Completed - - } - /> + {/* ── Startup Timing (bottom) ── */} +
From a6ae2610d2bf4977dc4ff0a63979d32f263cf440 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sat, 23 May 2026 15:21:33 +0200 Subject: [PATCH 075/164] Add Group-Based Licensing support --- .../CippFormLicenseSelector.jsx | 1 + .../CippFormPages/CippAddGroupForm.jsx | 16 ++ .../CippAddGroupTemplateForm.jsx | 16 ++ .../CippWizard/CippWizardGroupTemplates.jsx | 4 + .../administration/group-templates/edit.jsx | 1 + .../identity/administration/groups/edit.jsx | 234 +++++++++++------- 6 files changed, 186 insertions(+), 86 deletions(-) diff --git a/src/components/CippComponents/CippFormLicenseSelector.jsx b/src/components/CippComponents/CippFormLicenseSelector.jsx index 28d3db47f745..8ec20ec6478a 100644 --- a/src/components/CippComponents/CippFormLicenseSelector.jsx +++ b/src/components/CippComponents/CippFormLicenseSelector.jsx @@ -25,6 +25,7 @@ export const CippFormLicenseSelector = ({ addedField: addedField, tenantFilter: userSettingsDefaults.currentTenant ?? undefined, url: "/api/ListLicenses", + dataKey: "Results", labelField: (option) => `${getCippLicenseTranslation([option])} (${option?.availableUnits} available)`, valueField: "skuId", diff --git a/src/components/CippFormPages/CippAddGroupForm.jsx b/src/components/CippFormPages/CippAddGroupForm.jsx index 713bb414c638..6ce4b08ce3bc 100644 --- a/src/components/CippFormPages/CippAddGroupForm.jsx +++ b/src/components/CippFormPages/CippAddGroupForm.jsx @@ -4,6 +4,7 @@ import CippFormComponent from "../CippComponents/CippFormComponent"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "../CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; +import { CippFormLicenseSelector } from "../CippComponents/CippFormLicenseSelector"; const DynamicMembershipRules = ({ formControl }) => ( @@ -100,6 +101,21 @@ const CippAddGroupForm = (props) => { ]} /> + + + + + { const { formControl } = props; @@ -66,6 +67,21 @@ const CippAddGroupTemplateForm = (props) => { {/* Debug output */}
Current groupType: {formControl.watch("groupType")}
+ + + + + { formControl.setValue("membershipRules", watcher.addedFields.membershipRules, { shouldValidate: true, }); + formControl.setValue("licenses", watcher.addedFields.licenses || [], { + shouldValidate: true, + }); console.log("Set membershipRules to:", watcher.addedFields.membershipRules); }, 100); @@ -71,6 +74,7 @@ export const CippWizardGroupTemplates = (props) => { username: "username", allowExternal: "allowExternal", membershipRules: "membershipRules", + licenses: "licenses", }, showRefresh: true, }} diff --git a/src/pages/identity/administration/group-templates/edit.jsx b/src/pages/identity/administration/group-templates/edit.jsx index 8c83b0461005..5748cc165f72 100644 --- a/src/pages/identity/administration/group-templates/edit.jsx +++ b/src/pages/identity/administration/group-templates/edit.jsx @@ -44,6 +44,7 @@ const Page = () => { groupType: templateData.groupType, membershipRules: templateData.membershipRules, allowExternal: templateData.allowExternal, + licenses: templateData.licenses || [], tenantFilter: userSettingsDefaults.currentTenant, }); formControl.trigger(); diff --git a/src/pages/identity/administration/groups/edit.jsx b/src/pages/identity/administration/groups/edit.jsx index 4409353cb92b..a4ef9e9d5cb6 100644 --- a/src/pages/identity/administration/groups/edit.jsx +++ b/src/pages/identity/administration/groups/edit.jsx @@ -1,41 +1,43 @@ -import { useEffect, useState } from "react"; -import { Box, Button, Divider, Typography, Alert } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; -import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; -import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector"; -import { useRouter } from "next/router"; -import { ApiGetCall } from "../../../../api/ApiCall"; -import { useSettings } from "../../../../hooks/use-settings"; -import { CippFormContactSelector } from "../../../../components/CippComponents/CippFormContactSelector"; -import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; +import { useEffect, useState } from 'react' +import { Box, Button, Divider, Typography, Alert } from '@mui/material' +import { Grid } from '@mui/system' +import { useForm } from 'react-hook-form' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import CippFormPage from '../../../../components/CippFormPages/CippFormPage' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { CippFormUserSelector } from '../../../../components/CippComponents/CippFormUserSelector' +import { useRouter } from 'next/router' +import { ApiGetCall } from '../../../../api/ApiCall' +import { useSettings } from '../../../../hooks/use-settings' +import { CippFormContactSelector } from '../../../../components/CippComponents/CippFormContactSelector' +import { CippDataTable } from '../../../../components/CippTable/CippDataTable' +import { CippFormLicenseSelector } from '../../../../components/CippComponents/CippFormLicenseSelector' +import { getCippLicenseTranslation } from '../../../../utils/get-cipp-license-translation' const EditGroup = () => { - const router = useRouter(); - const { groupId, groupType } = router.query; - const [groupIdReady, setGroupIdReady] = useState(false); - const [showMembershipTable, setShowMembershipTable] = useState(false); - const [combinedData, setCombinedData] = useState([]); - const [initialValues, setInitialValues] = useState({}); - const tenantFilter = useSettings().currentTenant; + const router = useRouter() + const { groupId, groupType } = router.query + const [groupIdReady, setGroupIdReady] = useState(false) + const [showMembershipTable, setShowMembershipTable] = useState(false) + const [combinedData, setCombinedData] = useState([]) + const [initialValues, setInitialValues] = useState({}) + const tenantFilter = useSettings().currentTenant const groupInfo = ApiGetCall({ url: `/api/ListGroups?groupID=${groupId}&tenantFilter=${tenantFilter}&members=true&owners=true&groupType=${groupType}`, queryKey: `ListGroups-${groupId}`, waiting: groupIdReady, - }); + }) useEffect(() => { if (groupId) { - setGroupIdReady(true); - groupInfo.refetch(); + setGroupIdReady(true) + groupInfo.refetch() } - }, [router.query, groupId, tenantFilter]); + }, [router.query, groupId, tenantFilter]) const formControl = useForm({ - mode: "onChange", + mode: 'onChange', defaultValues: { tenantFilter: tenantFilter, AddMember: [], @@ -44,51 +46,53 @@ const EditGroup = () => { RemoveOwner: [], AddContact: [], RemoveContact: [], - visibility: "Public", + AddLicenses: [], + RemoveLicenses: [], + visibility: 'Public', }, - }); + }) useEffect(() => { if (groupInfo.isSuccess) { - const group = groupInfo.data?.groupInfo; + const group = groupInfo.data?.groupInfo if (group) { // Create combined data for the table const combinedData = [ ...(groupInfo.data?.owners?.map((o) => ({ - type: "Owner", + type: 'Owner', userPrincipalName: o.userPrincipalName, displayName: o.displayName, })) || []), ...(groupInfo.data?.members?.map((m) => ({ - type: m?.["@odata.type"] === "#microsoft.graph.orgContact" ? "Contact" : "Member", + type: m?.['@odata.type'] === '#microsoft.graph.orgContact' ? 'Contact' : 'Member', userPrincipalName: m.userPrincipalName ?? m.mail, displayName: m.displayName, })) || []), - ]; - setCombinedData(combinedData); + ] + setCombinedData(combinedData) // Create initial values object const formValues = { tenantFilter: tenantFilter, mail: group.mail, - mailNickname: group.mailNickname || "", + mailNickname: group.mailNickname || '', allowExternal: groupInfo?.data?.allowExternal, sendCopies: groupInfo?.data?.sendCopies, hideFromOutlookClients: groupInfo?.data?.hideFromOutlookClients, - visibility: group?.visibility ?? "Public", + visibility: group?.visibility ?? 'Public', displayName: group.displayName, - description: group.description || "", - membershipRules: group.membershipRule || "", + description: group.description || '', + membershipRules: group.membershipRule || '', groupId: group.id, groupType: (() => { - if (group.groupTypes?.includes("Unified")) { - return "Microsoft 365"; + if (group.groupTypes?.includes('Unified')) { + return 'Microsoft 365' } if (!group.mailEnabled && group.securityEnabled) { - return "Security"; + return 'Security' } if (group.mailEnabled && group.securityEnabled) { - return "Mail-Enabled Security"; + return 'Mail-Enabled Security' } if ( @@ -96,9 +100,9 @@ const EditGroup = () => { group.mailEnabled && !group.securityEnabled ) { - return "Distribution List"; + return 'Distribution List' } - return null; + return null })(), securityEnabled: group.securityEnabled, // Initialize empty arrays for add/remove actions @@ -108,7 +112,9 @@ const EditGroup = () => { RemoveOwner: [], AddContact: [], RemoveContact: [], - }; + AddLicenses: [], + RemoveLicenses: [], + } // Store initial values for comparison setInitialValues({ @@ -116,43 +122,43 @@ const EditGroup = () => { sendCopies: groupInfo?.data?.sendCopies, hideFromOutlookClients: groupInfo?.data?.hideFromOutlookClients, securityEnabled: group.securityEnabled, - visibility: group.visibility ?? "Public", - }); + visibility: group.visibility ?? 'Public', + }) // Reset the form with all values - formControl.reset(formValues); + formControl.reset(formValues) } } - }, [groupInfo.isSuccess, router.query, groupInfo.isFetching]); + }, [groupInfo.isSuccess, router.query, groupInfo.isFetching]) // Custom data formatter to only send changed values const customDataFormatter = (formData) => { - const cleanedData = { ...formData }; + const cleanedData = { ...formData } // Properties that should only be sent if they've changed from initial values const changeDetectionProperties = [ - "allowExternal", - "sendCopies", - "hideFromOutlookClients", - "securityEnabled", - "visibility", - ]; + 'allowExternal', + 'sendCopies', + 'hideFromOutlookClients', + 'securityEnabled', + 'visibility', + ] changeDetectionProperties.forEach((property) => { if (formData[property] === initialValues[property]) { - delete cleanedData[property]; + delete cleanedData[property] } - }); + }) - return cleanedData; - }; + return cleanedData + } return ( <> { onClick={() => setShowMembershipTable(!showMembershipTable)} sx={{ mb: 2 }} > - {showMembershipTable ? "Edit Membership" : "View members"} + {showMembershipTable ? 'Edit Membership' : 'View members'} } @@ -181,7 +187,7 @@ const EditGroup = () => { @@ -225,7 +231,7 @@ const EditGroup = () => { /> - {groupInfo.data?.groupInfo?.groupTypes?.includes("DynamicMembership") && ( + {groupInfo.data?.groupInfo?.groupTypes?.includes('DynamicMembership') && ( { isFetching={groupInfo.isFetching} disabled={groupInfo.isFetching} addedField={{ - id: "id", - displayName: "displayName", - userPrincipalName: "userPrincipalName", + id: 'id', + displayName: 'displayName', + userPrincipalName: 'userPrincipalName', }} dataFilter={(option) => !groupInfo.data?.members?.some((m) => m.id === option.value) @@ -272,9 +278,9 @@ const EditGroup = () => { isFetching={groupInfo.isFetching} disabled={groupInfo.isFetching} addedField={{ - id: "id", - displayName: "displayName", - userPrincipalName: "userPrincipalName", + id: 'id', + displayName: 'displayName', + userPrincipalName: 'userPrincipalName', }} dataFilter={(option) => !groupInfo.data?.owners?.some((o) => o.id === option.value) @@ -289,15 +295,15 @@ const EditGroup = () => { label="Add Contacts" multiple={true} addedField={{ - id: "Guid", - displayName: "displayName", - WindowsEmailAddress: "WindowsEmailAddress", + id: 'Guid', + displayName: 'displayName', + WindowsEmailAddress: 'WindowsEmailAddress', }} isFetching={groupInfo.isFetching} disabled={groupInfo.isFetching} dataFilter={(option) => !groupInfo.data?.members - ?.filter((m) => m?.["@odata.type"] === "#microsoft.graph.orgContact") + ?.filter((m) => m?.['@odata.type'] === '#microsoft.graph.orgContact') ?.some((c) => c.id === option.value) } /> @@ -319,7 +325,7 @@ const EditGroup = () => { disabled={groupInfo.isFetching} options={ groupInfo.data?.members - ?.filter((m) => m?.["@odata.type"] !== "#microsoft.graph.orgContact") + ?.filter((m) => m?.['@odata.type'] !== '#microsoft.graph.orgContact') ?.map((m) => ({ label: `${m.displayName} (${m.userPrincipalName})`, value: m.id, @@ -369,7 +375,7 @@ const EditGroup = () => { disabled={groupInfo.isFetching} options={ groupInfo.data?.members - ?.filter((m) => m?.["@odata.type"] === "#microsoft.graph.orgContact") + ?.filter((m) => m?.['@odata.type'] === '#microsoft.graph.orgContact') ?.map((m) => ({ label: `${m.displayName} (${m.mail})`, value: m.mail, @@ -385,7 +391,7 @@ const EditGroup = () => { Group Settings - {groupType === "Microsoft 365" && ( + {groupType === 'Microsoft 365' && ( { isFetching={groupInfo.isFetching} disabled={groupInfo.isFetching} options={[ - { label: "Public", value: "Public" }, - { label: "Private", value: "Private" }, + { label: 'Public', value: 'Public' }, + { label: 'Private', value: 'Private' }, ]} /> )} - {(groupType === "Microsoft 365" || groupType === "Distribution List") && ( + {(groupType === 'Microsoft 365' || groupType === 'Distribution List') && ( { )} - {groupType === "Microsoft 365" && ( + {groupType === 'Microsoft 365' && ( { )} - {groupType === "Microsoft 365" && ( + {groupType === 'Microsoft 365' && ( { /> )} - {groupType === "Microsoft 365" && ( + {groupType === 'Microsoft 365' && ( { /> )} + + {groupType === 'Security' && !groupInfo.data?.groupInfo?.onPremisesSyncEnabled && ( + <> + + Licenses + + Licenses assigned to this group are automatically applied to all members. + Changes can take 2-5 minutes to propagate. + + + + {groupInfo.data?.groupInfo?.assignedLicenses?.length > 0 && ( + + + Currently assigned licenses: + + {groupInfo.data.groupInfo.assignedLicenses.map((lic) => ( + + - {getCippLicenseTranslation([lic])} + + ))} + + )} + + + + + + + ({ + label: getCippLicenseTranslation([lic]), + value: lic.skuId, + })) || [] + } + sortOptions={true} + /> + + + )} )} - ); -}; + ) +} -EditGroup.getLayout = (page) => {page}; +EditGroup.getLayout = (page) => {page} -export default EditGroup; +export default EditGroup From a32790443fc1297fab6900acc498df9cff107aa0 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 24 May 2026 09:13:35 +1000 Subject: [PATCH 076/164] CIPP Hosted Notices --- .../CippComponents/FailedPaymentDialog.jsx | 49 +++++++++++++++++++ .../SubscriptionEndedDialog.jsx | 25 ++++++++++ src/layouts/index.js | 4 ++ 3 files changed, 78 insertions(+) create mode 100644 src/components/CippComponents/FailedPaymentDialog.jsx create mode 100644 src/components/CippComponents/SubscriptionEndedDialog.jsx diff --git a/src/components/CippComponents/FailedPaymentDialog.jsx b/src/components/CippComponents/FailedPaymentDialog.jsx new file mode 100644 index 000000000000..5668f619c443 --- /dev/null +++ b/src/components/CippComponents/FailedPaymentDialog.jsx @@ -0,0 +1,49 @@ +import { useEffect, useState, useCallback } from 'react' +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material' + +const DISMISS_KEY = 'cipp_hosted_payment_dismissed' +const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day + +export const FailedPaymentDialog = ({ hostedFailedPayments }) => { + const [open, setOpen] = useState(false) + + useEffect(() => { + if (!hostedFailedPayments) return + + const dismissedAt = localStorage.getItem(DISMISS_KEY) + if (dismissedAt && Date.now() - Number(dismissedAt) < DISMISS_DURATION_MS) return + + setOpen(true) + }, [hostedFailedPayments]) + + const handleDismiss = useCallback(() => { + localStorage.setItem(DISMISS_KEY, String(Date.now())) + setOpen(false) + }, []) + + return ( + e.stopPropagation() } }} + > + Payment Issue + + + There is a payment issue with your CIPP subscription. + + + A recent payment has failed. Please contact your account holder to update payment + information and avoid service interruption. + + + + + + + ) +} diff --git a/src/components/CippComponents/SubscriptionEndedDialog.jsx b/src/components/CippComponents/SubscriptionEndedDialog.jsx new file mode 100644 index 000000000000..e715cce54d4e --- /dev/null +++ b/src/components/CippComponents/SubscriptionEndedDialog.jsx @@ -0,0 +1,25 @@ +import { Alert, Dialog, DialogContent, DialogTitle, Typography } from '@mui/material' + +export const SubscriptionEndedDialog = ({ hostedSubscriptionEnded }) => { + const open = !!hostedSubscriptionEnded + + return ( + e.stopPropagation() } }} + > + Subscription Ended + + + Your CIPP subscription has ended. Access to this instance is no longer available. + + + Please contact your account holder to renew the subscription and restore access. + + + + ) +} diff --git a/src/layouts/index.js b/src/layouts/index.js index f69628a3c706..07f2f152a562 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -29,6 +29,8 @@ import { nativeMenuItems } from './config' import { CippBreadcrumbNav } from '../components/CippComponents/CippBreadcrumbNav' import { SsoMigrationDialog } from '../components/CippComponents/SsoMigrationDialog' import { ForcedSsoMigrationDialog } from '../components/CippComponents/ForcedSsoMigrationDialog' +import { SubscriptionEndedDialog } from '../components/CippComponents/SubscriptionEndedDialog' +import { FailedPaymentDialog } from '../components/CippComponents/FailedPaymentDialog' const OnboardingWizardPage = dynamic( () => import('../components/CippWizard/OnboardingWizardPage.jsx'), @@ -337,6 +339,8 @@ export const Layout = (props) => { + + {!setupCompleted && ( From 04c63849f689bbd2686fb473cbaef1462a1d1b99 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 24 May 2026 22:41:10 +0200 Subject: [PATCH 077/164] implement standards template deployment for intune apps --- src/data/standards.json | 361 ++++++++++------------------------------ 1 file changed, 84 insertions(+), 277 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index 7748d7dee0c7..266bb8af19cd 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -126,16 +126,8 @@ { "name": "standards.AuditLog", "cat": "Global Standards", - "tag": [ - "CIS M365 6.0.1 (3.1.1)", - "mip_search_auditlog", - "NIST CSF 2.0 (DE.CM-09)" - ], - "appliesToTest": [ - "CISAMSEXO171", - "CISAMSEXO173", - "CIS_3_1_1" - ], + "tag": ["CIS M365 6.0.1 (3.1.1)", "mip_search_auditlog", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CISAMSEXO171", "CISAMSEXO173", "CIS_3_1_1"], "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.", "addedComponent": [], @@ -368,11 +360,7 @@ "name": "standards.DisableBasicAuthSMTP", "cat": "Global Standards", "tag": ["CIS M365 6.0.1 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)"], - "appliesToTest": [ - "CISAMSEXO51", - "CIS_6_5_4", - "ZTNA21799" - ], + "appliesToTest": ["CISAMSEXO51", "CIS_6_5_4", "ZTNA21799"], "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)", "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).", "executiveText": "Disables outdated email authentication methods that are vulnerable to security attacks, forcing applications and devices to use modern, more secure authentication protocols. This reduces the risk of email-based security breaches and credential theft.", @@ -394,17 +382,8 @@ { "name": "standards.ActivityBasedTimeout", "cat": "Global Standards", - "tag": [ - "CIS M365 6.0.1 (1.3.2)", - "spo_idle_session_timeout", - "NIST CSF 2.0 (PR.AA-03)" - ], - "appliesToTest": [ - "CIS_1_3_2", - "ZTNA21813", - "ZTNA21814", - "ZTNA21815" - ], + "tag": ["CIS M365 6.0.1 (1.3.2)", "spo_idle_session_timeout", "NIST CSF 2.0 (PR.AA-03)"], + "appliesToTest": ["CIS_1_3_2", "ZTNA21813", "ZTNA21814", "ZTNA21815"], "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.", "addedComponent": [ @@ -490,10 +469,7 @@ "name": "standards.AdminSSPR", "cat": "Entra (AAD) Standards", "tag": ["EIDSCA.AP01"], - "appliesToTest": [ - "EIDSCAAP01", - "ZTNA21842" - ], + "appliesToTest": ["EIDSCAAP01", "ZTNA21842"], "helpText": "Controls whether administrators are allowed to use Self-Service Password Reset through the Microsoft Entra authorization policy.", "docsDescription": "Configures the allowedToUseSSPR property on the Microsoft Entra authorization policy. Microsoft documents this property as controlling whether administrators of the tenant can use Self-Service Password Reset. Use this standard to explicitly enable or disable administrator SSPR based on your security policy.", "executiveText": "Controls whether tenant administrators can reset their own passwords through Self-Service Password Reset. Disabling this capability forces privileged accounts through more controlled recovery processes and reduces the risk of self-service recovery being misused on administrative identities.", @@ -593,13 +569,7 @@ "name": "standards.laps", "cat": "Entra (AAD) Standards", "tag": ["CIS M365 6.0.1 (5.1.4.5)", "SMB1001 (2.2)"], - "appliesToTest": [ - "CIS_5_1_4_5", - "SMB1001_2_2", - "ZTNA21953", - "ZTNA21955", - "ZTNA24560" - ], + "appliesToTest": ["CIS_5_1_4_5", "SMB1001_2_2", "ZTNA21953", "ZTNA21955", "ZTNA24560"], "helpText": "Enables the tenant to use LAPS. You must still create a policy for LAPS to be active on all devices. Use the template standards to deploy this by default.", "docsDescription": "Enables the LAPS functionality on the tenant. Prerequisite for using Windows LAPS via Azure AD.", "executiveText": "Enables Local Administrator Password Solution (LAPS) capability, which automatically manages and rotates local administrator passwords on company computers. This significantly improves security by preventing the use of shared or static administrator passwords that could be exploited by attackers.", @@ -733,11 +703,7 @@ "name": "standards.EnableHardwareOAuth", "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)"], - "appliesToTest": [ - "SMB1001_2_5", - "SMB1001_2_6", - "SMB1001_2_9" - ], + "appliesToTest": ["SMB1001_2_5", "SMB1001_2_6", "SMB1001_2_9"], "helpText": "Enables the HardwareOath authenticationMethod for the tenant. This allows you to use hardware tokens for generating 6 digit MFA codes.", "docsDescription": "Enables Hardware OAuth tokens for the tenant. This allows users to use hardware tokens like a Yubikey for authentication.", "executiveText": "Enables physical hardware tokens that generate secure authentication codes, providing an alternative to smartphone-based authentication. This is particularly valuable for employees who cannot use mobile devices or require the highest security standards for accessing sensitive systems.", @@ -753,10 +719,7 @@ "name": "standards.allowOAuthTokens", "cat": "Entra (AAD) Standards", "tag": ["EIDSCA.AT01", "EIDSCA.AT02"], - "appliesToTest": [ - "EIDSCAAT01", - "EIDSCAAT02" - ], + "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02"], "helpText": "Allows you to use any software OAuth token generator", "docsDescription": "Enables OTP Software OAuth tokens for the tenant. This allows users to use OTP codes generated via software, like a password manager to be used as an authentication method.", "executiveText": "Allows employees to use third-party authentication apps and password managers to generate secure login codes, providing flexibility in authentication methods while maintaining security standards. This accommodates diverse user preferences and existing security tools.", @@ -788,12 +751,7 @@ "name": "standards.TAP", "cat": "Entra (AAD) Standards", "tag": [], - "appliesToTest": [ - "EIDSCAAT01", - "EIDSCAAT02", - "ZTNA21845", - "ZTNA21846" - ], + "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", "docsDescription": "Enables Temporary Password generation for the tenant.", "executiveText": "Enables temporary access passwords that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passwords provide a secure way to restore access without compromising long-term security policies.", @@ -836,10 +794,7 @@ { "name": "standards.CustomBannedPasswordList", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 6.0.1 (5.2.3.2)", - "SMB1001 (2.1)" - ], + "tag": ["CIS M365 6.0.1 (5.2.3.2)", "SMB1001 (2.1)"], "appliesToTest": [ "CIS_5_2_3_2", "EIDSCAPR01", @@ -875,10 +830,7 @@ "name": "standards.ExternalMFATrusted", "cat": "Entra (AAD) Standards", "tag": [], - "appliesToTest": [ - "ZTNA21803", - "ZTNA21804" - ], + "appliesToTest": ["ZTNA21803", "ZTNA21804"], "helpText": "Sets the state of the Cross-tenant access setting to trust external MFA. This allows guest users to use their home tenant MFA to access your tenant.", "executiveText": "Allows external partners and vendors to use their own organization's multi-factor authentication when accessing company resources, streamlining collaboration while maintaining security standards. This reduces friction for external users while ensuring they still meet authentication requirements.", "addedComponent": [ @@ -904,17 +856,8 @@ { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 6.0.1 (5.1.2.3)", - "CISA (MS.AAD.6.1v1)", - "SMB1001 (2.8)" - ], - "appliesToTest": [ - "CIS_5_1_2_3", - "SMB1001_2_8", - "ZTNA21772", - "ZTNA21787" - ], + "tag": ["CIS M365 6.0.1 (5.1.2.3)", "CISA (MS.AAD.6.1v1)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_2_3", "SMB1001_2_8", "ZTNA21772", "ZTNA21787"], "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles.", "docsDescription": "Users by default are allowed to create M365 tenants. This disables that so only admins can create new M365 tenants.", "executiveText": "Prevents regular employees from creating new Microsoft 365 organizations, ensuring all new tenants are properly managed and controlled by IT administrators. This prevents unauthorized shadow IT environments and maintains centralized governance over Microsoft 365 resources.", @@ -971,10 +914,7 @@ "name": "standards.NudgeMFA", "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.5)"], - "appliesToTest": [ - "SMB1001_2_5", - "ZTNA21889" - ], + "appliesToTest": ["SMB1001_2_5", "ZTNA21889"], "helpText": "Sets the state of the registration campaign for the tenant", "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.", "executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.", @@ -1012,10 +952,7 @@ "name": "standards.DisableM365GroupUsers", "cat": "Entra (AAD) Standards", "tag": ["CISA (MS.AAD.21.1v1)", "SMB1001 (2.8)"], - "appliesToTest": [ - "SMB1001_2_8", - "ZTNA21868" - ], + "appliesToTest": ["SMB1001_2_8", "ZTNA21868"], "helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments.", @@ -1046,11 +983,7 @@ "NIST CSF 2.0 (PR.AA-05)", "SMB1001 (2.8)" ], - "appliesToTest": [ - "CIS_5_1_2_2", - "EIDSCAAP10", - "SMB1001_2_8" - ], + "appliesToTest": ["CIS_5_1_2_2", "EIDSCAAP10", "SMB1001_2_8"], "helpText": "Disables the ability for users to create App registrations in the tenant.", "docsDescription": "Disables the ability for users to create applications in Entra. Done to prevent breached accounts from creating an app to maintain access to the tenant, even after the breached account has been secured.", "executiveText": "Prevents regular employees from creating application registrations that could be used to maintain unauthorized access to company systems. This security measure ensures that only authorized IT personnel can create applications, reducing the risk of persistent security breaches through malicious applications.", @@ -1066,10 +999,7 @@ "name": "standards.BitLockerKeysForOwnedDevice", "cat": "Entra (AAD) Standards", "tag": ["CIS M365 6.0.1 (5.1.4.6)"], - "appliesToTest": [ - "CIS_5_1_4_6", - "ZTNA21954" - ], + "appliesToTest": ["CIS_5_1_4_6", "ZTNA21954"], "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.", @@ -1102,11 +1032,7 @@ "NIST CSF 2.0 (PR.AA-05)", "SMB1001 (2.8)" ], - "appliesToTest": [ - "CIS_5_1_3_2", - "SMB1001_2_8", - "ZTNA21868" - ], + "appliesToTest": ["CIS_5_1_3_2", "SMB1001_2_8", "ZTNA21868"], "helpText": "Completely disables the creation of security groups by users. This also breaks the ability to manage groups themselves, or create Teams", "executiveText": "Restricts the creation of security groups to IT administrators only, preventing employees from creating unauthorized access groups that could bypass security controls. This ensures proper governance of access permissions and maintains centralized control over who can access what resources.", "addedComponent": [], @@ -1135,11 +1061,7 @@ "name": "standards.DisableSelfServiceLicenses", "cat": "Entra (AAD) Standards", "tag": ["CIS M365 6.0.1 (1.3.4)", "SMB1001 (2.8)"], - "appliesToTest": [ - "CIS_1_3_4", - "EIDSCAAP05", - "SMB1001_2_8" - ], + "appliesToTest": ["CIS_1_3_4", "EIDSCAAP05", "SMB1001_2_8"], "helpText": "**Requires 'Billing Administrator' GDAP role.** This standard disables all self service licenses and enables all exclusions", "executiveText": "Prevents employees from purchasing Microsoft 365 licenses independently, ensuring all software acquisitions go through proper procurement channels. This maintains budget control, prevents unauthorized spending, and ensures compliance with corporate licensing agreements.", "addedComponent": [ @@ -1166,10 +1088,7 @@ "name": "standards.DisableGuests", "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.8)"], - "appliesToTest": [ - "SMB1001_2_8", - "ZTNA21858" - ], + "appliesToTest": ["SMB1001_2_8", "ZTNA21858"], "helpText": "Blocks login for guest users that have not logged in for a number of days", "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", "addedComponent": [ @@ -1247,19 +1166,8 @@ { "name": "standards.GuestInvite", "cat": "Entra (AAD) Standards", - "tag": [ - "CISA (MS.AAD.18.1v1)", - "EIDSCA.AP04", - "EIDSCA.AP07", - "SMB1001 (2.8)" - ], - "appliesToTest": [ - "CIS_5_1_6_3", - "EIDSCAAP04", - "EIDSCAAP07", - "SMB1001_2_8", - "ZTNA21791" - ], + "tag": ["CISA (MS.AAD.18.1v1)", "EIDSCA.AP04", "EIDSCA.AP07", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_6_3", "EIDSCAAP04", "EIDSCAAP07", "SMB1001_2_8", "ZTNA21791"], "helpText": "This setting controls who can invite guests to your directory to collaborate on resources secured by your company, such as SharePoint sites or Azure resources.", "executiveText": "Controls who within the organization can invite external partners and vendors to access company resources, ensuring proper oversight of external access while enabling necessary business collaboration. This helps maintain security while supporting partnership and vendor relationships.", "addedComponent": [ @@ -1332,12 +1240,7 @@ "name": "standards.SecurityDefaults", "cat": "Entra (AAD) Standards", "tag": ["CISA (MS.AAD.11.1v1)", "SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)"], - "appliesToTest": [ - "SMB1001_2_5", - "SMB1001_2_6", - "SMB1001_2_9", - "ZTNA21843" - ], + "appliesToTest": ["SMB1001_2_5", "SMB1001_2_6", "SMB1001_2_9", "ZTNA21843"], "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access.", "docsDescription": "Enables SD for the tenant, which disables all forms of basic authentication and enforces users to configure MFA. Users are only prompted for MFA when a logon is considered 'suspect' by Microsoft.", "executiveText": "Activates Microsoft's baseline security configuration that requires multi-factor authentication and blocks legacy authentication methods. This provides essential security protection for organizations without complex conditional access policies, significantly improving security posture with minimal configuration.", @@ -1419,13 +1322,7 @@ "SMB1001 (2.6)", "SMB1001 (2.9)" ], - "appliesToTest": [ - "CIS_5_2_3_7", - "SMB1001_2_5", - "SMB1001_2_5_L4", - "SMB1001_2_6", - "SMB1001_2_9" - ], + "appliesToTest": ["CIS_5_2_3_7", "SMB1001_2_5", "SMB1001_2_5_L4", "SMB1001_2_6", "SMB1001_2_9"], "helpText": "This blocks users from using email as an MFA method. This disables the email OTP option for guest users, and instead prompts them to create a Microsoft account.", "executiveText": "Disables email-based authentication codes due to security concerns with email interception and account compromise. This forces users to adopt more secure authentication methods, particularly affecting guest users who must use stronger verification methods.", "addedComponent": [], @@ -1856,12 +1753,7 @@ "name": "standards.SpoofWarn", "cat": "Exchange Standards", "tag": ["CIS M365 6.0.1 (6.2.3)"], - "appliesToTest": [ - "CISAMSEXO71", - "CIS_6_2_3", - "ORCA111", - "ORCA240" - ], + "appliesToTest": ["CISAMSEXO71", "CIS_6_2_3", "ORCA111", "ORCA240"], "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", "executiveText": "Displays visual warnings in Outlook when emails come from external senders, helping employees identify potentially suspicious messages and reducing the risk of phishing attacks. This security feature makes it easier for staff to distinguish between internal and external communications.", @@ -1981,10 +1873,7 @@ "name": "standards.RotateDKIM", "cat": "Exchange Standards", "tag": ["CIS M365 6.0.1 (2.1.9)", "SMB1001 (2.12)"], - "appliesToTest": [ - "CIS_2_1_9", - "SMB1001_2_12" - ], + "appliesToTest": ["CIS_2_1_9", "SMB1001_2_12"], "helpText": "Rotate DKIM keys that are 1024 bit to 2048 bit", "executiveText": "Upgrades email security by replacing older 1024-bit encryption keys with stronger 2048-bit keys for email authentication. This improves the organization's email security posture and helps prevent email spoofing and tampering, maintaining trust with email recipients.", "addedComponent": [], @@ -2038,13 +1927,7 @@ "name": "standards.AddDKIM", "cat": "Exchange Standards", "tag": ["CIS M365 6.0.1 (2.1.9)", "SMB1001 (2.12)"], - "appliesToTest": [ - "CISAMSEXO31", - "CIS_2_1_9", - "ORCA108", - "ORCA108_1", - "SMB1001_2_12" - ], + "appliesToTest": ["CISAMSEXO31", "CIS_2_1_9", "ORCA108", "ORCA108_1", "SMB1001_2_12"], "helpText": "Enables DKIM for all domains that currently support it", "executiveText": "Enables email authentication technology that digitally signs outgoing emails to verify they actually came from your organization. This prevents email spoofing, improves email deliverability, and protects the company's reputation by ensuring recipients can trust emails from your domains.", "addedComponent": [], @@ -2066,10 +1949,7 @@ "name": "standards.AddDMARCToMOERA", "cat": "Global Standards", "tag": ["CIS M365 6.0.1 (2.1.10)", "Security", "PhishingProtection", "SMB1001 (2.12)"], - "appliesToTest": [ - "CIS_2_1_10", - "SMB1001_2_12" - ], + "appliesToTest": ["CIS_2_1_10", "SMB1001_2_12"], "helpText": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default value is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "docsDescription": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. Adds a DMARC record to MOERA (onmicrosoft.com) domains. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default record is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "executiveText": "Implements advanced email security for Microsoft's default domain names (onmicrosoft.com) to prevent criminals from impersonating your organization. This blocks fraudulent emails that could damage your company's reputation and protects partners and customers from phishing attacks using your domain names.", @@ -2107,12 +1987,7 @@ "Essential 8 (1683)", "NIST CSF 2.0 (DE.CM-09)" ], - "appliesToTest": [ - "CISAMSEXO131", - "CIS_6_1_1", - "CIS_6_1_2", - "CIS_6_1_3" - ], + "appliesToTest": ["CISAMSEXO131", "CIS_6_1_1", "CIS_6_1_2", "CIS_6_1_3"], "helpText": "Enables Mailbox auditing for all mailboxes and on tenant level. Disables audit bypass on all mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "docsDescription": "Enables mailbox auditing on tenant level and for all mailboxes. Disables audit bypass on all mailboxes. By default Microsoft does not enable mailbox auditing for Resource Mailboxes, Public Folder Mailboxes and DiscoverySearch Mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "executiveText": "Enables comprehensive logging of all email access and modifications across all employee mailboxes, providing detailed audit trails for security investigations and compliance requirements. This helps detect unauthorized access, data breaches, and supports regulatory compliance efforts.", @@ -2383,11 +2258,7 @@ "name": "standards.DisableExternalCalendarSharing", "cat": "Exchange Standards", "tag": ["CIS M365 6.0.1 (1.3.3)", "exo_individualsharing"], - "appliesToTest": [ - "CISAMSEXO62", - "CIS_1_3_3", - "ZTNA21803" - ], + "appliesToTest": ["CISAMSEXO62", "CIS_1_3_3", "ZTNA21803"], "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", "executiveText": "Prevents employees from sharing their calendars with external parties, protecting sensitive meeting information and internal schedules from unauthorized access. This security measure helps maintain confidentiality of business activities while still allowing internal collaboration.", @@ -2426,10 +2297,7 @@ "name": "standards.DisableAdditionalStorageProviders", "cat": "Exchange Standards", "tag": ["CIS M365 6.0.1 (6.5.3)", "exo_storageproviderrestricted"], - "appliesToTest": [ - "CIS_6_5_3", - "ZTNA21817" - ], + "appliesToTest": ["CIS_6_5_3", "ZTNA21817"], "helpText": "Disables the ability for users to open files in Outlook on the Web, from other providers such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.", "docsDescription": "Disables additional storage providers in OWA. This is to prevent users from using personal storage providers like Dropbox, Google Drive, etc. Usually this has little user impact.", "executiveText": "Prevents employees from accessing personal cloud storage services like Dropbox or Google Drive through Outlook on the web, reducing data security risks and ensuring company information stays within approved corporate systems. This helps maintain data governance and prevents accidental data leaks.", @@ -2601,10 +2469,7 @@ "NIST CSF 2.0 (PR.AA-05)", "NIST CSF 2.0 (PR.PS-05)" ], - "appliesToTest": [ - "CIS_6_3_1", - "ZTNA21817" - ], + "appliesToTest": ["CIS_6_3_1", "ZTNA21817"], "helpText": "Disables the ability for users to install add-ins in Outlook. This is to prevent users from installing malicious add-ins.", "docsDescription": "Disables users from being able to install add-ins in Outlook. Only admins are able to approve add-ins for the users. This is done to reduce the threat surface for data exfiltration.", "executiveText": "Prevents employees from installing third-party add-ins in Outlook without administrative approval, reducing security risks from potentially malicious extensions. This ensures only vetted and approved tools can access company email data while maintaining centralized control over email functionality.", @@ -2756,10 +2621,7 @@ "NIST CSF 2.0 (PR.AA-01)", "SMB1001 (2.3)" ], - "appliesToTest": [ - "CIS_1_2_2", - "SMB1001_2_3" - ], + "appliesToTest": ["CIS_1_2_2", "SMB1001_2_3"], "helpText": "Blocks login for all accounts that are marked as a shared mailbox. This is Microsoft best practice to prevent direct logons to shared mailboxes.", "docsDescription": "Shared mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for shared mailboxes. It would be a good idea to review the sign-in reports to establish potential impact.", "executiveText": "Prevents direct login to shared mailbox accounts (like info@company.com), ensuring they can only be accessed through authorized users' accounts. This security measure eliminates the risk of shared passwords and unauthorized access while maintaining proper access control and audit trails.", @@ -3239,12 +3101,7 @@ "mdo_safeattachmentpolicy", "NIST CSF 2.0 (DE.CM-09)" ], - "appliesToTest": [ - "CIS_2_1_4", - "ORCA158", - "ORCA189", - "ORCA227" - ], + "appliesToTest": ["CIS_2_1_4", "ORCA158", "ORCA189", "ORCA227"], "helpText": "This creates a Safe Attachment policy", "addedComponent": [ { @@ -3341,10 +3198,7 @@ "name": "standards.PhishingSimulations", "cat": "Defender Standards", "tag": ["SMB1001 (1.11)", "SMB1001 (5.1)"], - "appliesToTest": [ - "SMB1001_1_11", - "SMB1001_5_1" - ], + "appliesToTest": ["SMB1001_1_11", "SMB1001_5_1"], "helpText": "This creates a phishing simulation policy that enables phishing simulations for the entire tenant.", "addedComponent": [ { @@ -4394,12 +4248,7 @@ "name": "standards.intuneDeviceReg", "cat": "Intune Standards", "tag": ["CIS M365 6.0.1 (5.1.4.2)", "CISA (MS.AAD.17.1v1)"], - "appliesToTest": [ - "CIS_5_1_4_2", - "ZTNA21801", - "ZTNA21802", - "ZTNA21837" - ], + "appliesToTest": ["CIS_5_1_4_2", "ZTNA21801", "ZTNA21802", "ZTNA21837"], "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", "executiveText": "Limits how many devices each employee can register for corporate access, preventing excessive device proliferation while accommodating legitimate business needs. This helps maintain security oversight and prevents potential abuse of device registration privileges.", "addedComponent": [ @@ -4422,11 +4271,7 @@ "name": "standards.intuneDeviceRegLocalAdmins", "cat": "Entra (AAD) Standards", "tag": ["CIS M365 6.0.1 (5.1.4.3)", "CIS M365 6.0.1 (5.1.4.4)", "SMB1001 (2.2)"], - "appliesToTest": [ - "CIS_5_1_4_3", - "CIS_5_1_4_4", - "SMB1001_2_2" - ], + "appliesToTest": ["CIS_5_1_4_3", "CIS_5_1_4_4", "SMB1001_2_2"], "helpText": "Controls whether users who register Microsoft Entra joined devices are granted local administrator rights on those devices and if Global Administrators are added as local admins.", "docsDescription": "Configures the Device Registration Policy local administrator behavior for registering users. When enabled, users who register devices are not granted local administrator rights, you can also configure if Global Administrators are added as local admins.", "executiveText": "Controls whether employees who enroll devices automatically receive local administrator access. Disabling registering-user admin rights follows least-privilege principles and reduces security risk from over-privileged endpoints.", @@ -4478,10 +4323,7 @@ "name": "standards.intuneRestrictUserDeviceJoin", "cat": "Entra (AAD) Standards", "tag": ["CIS M365 6.0.1 (5.1.4.1)", "SMB1001 (2.8)"], - "appliesToTest": [ - "CIS_5_1_4_1", - "SMB1001_2_8" - ], + "appliesToTest": ["CIS_5_1_4_1", "SMB1001_2_8"], "helpText": "Controls whether users can join devices to Entra.", "docsDescription": "Configures whether users can join devices to Entra. When disabled, users are unable to Entra-join devices, which prevents them from creating new Entra-joined (cloud-managed) device identities.", "executiveText": "Controls whether employees can join their devices to the corporate Entra directory. Disabling user device join prevents unauthorized or unmanaged devices from becoming corporate-managed identities, enhancing overall security posture.", @@ -4504,11 +4346,7 @@ "name": "standards.intuneRequireMFA", "cat": "Intune Standards", "tag": [], - "appliesToTest": [ - "ZTNA21782", - "ZTNA21796", - "ZTNA21872" - ], + "appliesToTest": ["ZTNA21782", "ZTNA21796", "ZTNA21872"], "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", "executiveText": "Requires employees to use multi-factor authentication when registering devices for corporate access, adding an extra security layer to prevent unauthorized device enrollment. This helps ensure only legitimate users can connect their devices to company systems.", "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", @@ -4656,15 +4494,8 @@ { "name": "standards.SPDisallowInfectedFiles", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.3.1)", - "CISA (MS.SPO.3.1v1)", - "NIST CSF 2.0 (DE.CM-09)" - ], - "appliesToTest": [ - "CIS_7_3_1", - "ZTNA21817" - ], + "tag": ["CIS M365 6.0.1 (7.3.1)", "CISA (MS.SPO.3.1v1)", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CIS_7_3_1", "ZTNA21817"], "helpText": "Ensure Office 365 SharePoint infected files are disallowed for download", "executiveText": "Prevents employees from downloading files that have been identified as containing malware or viruses from SharePoint and OneDrive. This security measure protects against malware distribution through file sharing while maintaining access to clean, safe documents.", "addedComponent": [], @@ -4731,12 +4562,7 @@ "name": "standards.SPExternalUserExpiration", "cat": "SharePoint Standards", "tag": ["CIS M365 6.0.1 (7.2.9)", "CISA (MS.SPO.1.5v1)"], - "appliesToTest": [ - "CIS_7_2_9", - "ZTNA21803", - "ZTNA21804", - "ZTNA21858" - ], + "appliesToTest": ["CIS_7_2_9", "ZTNA21803", "ZTNA21804", "ZTNA21858"], "helpText": "Ensure guest access to a site or OneDrive will expire automatically", "executiveText": "Automatically expires external user access to SharePoint sites and OneDrive after a specified period, reducing security risks from forgotten or unnecessary guest accounts. This ensures external access is regularly reviewed and maintained only when actively needed.", "addedComponent": [ @@ -4770,11 +4596,7 @@ "name": "standards.SPEmailAttestation", "cat": "SharePoint Standards", "tag": ["CIS M365 6.0.1 (7.2.10)", "CISA (MS.SPO.1.6v1)"], - "appliesToTest": [ - "CIS_7_2_10", - "ZTNA21803", - "ZTNA21804" - ], + "appliesToTest": ["CIS_7_2_10", "ZTNA21803", "ZTNA21804"], "helpText": "Ensure re-authentication with verification code is restricted", "executiveText": "Requires external users to periodically re-verify their identity through email verification codes when accessing SharePoint resources, adding an extra security layer for external collaboration. This helps ensure continued legitimacy of external access over time.", "addedComponent": [ @@ -4807,17 +4629,8 @@ { "name": "standards.DefaultSharingLink", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.2.7)", - "CIS M365 6.0.1 (7.2.11)", - "CISA (MS.SPO.1.4v1)" - ], - "appliesToTest": [ - "CIS_7_2_11", - "CIS_7_2_7", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 6.0.1 (7.2.7)", "CIS M365 6.0.1 (7.2.11)", "CISA (MS.SPO.1.4v1)"], + "appliesToTest": ["CIS_7_2_11", "CIS_7_2_7", "ZTNA21803", "ZTNA21804"], "helpText": "Configure the SharePoint default sharing link type and permission. This setting controls both the type of sharing link created by default and the permission level assigned to those links.", "docsDescription": "Sets the default sharing link type (Direct or Internal) and permission (View) in SharePoint and OneDrive. Direct sharing means links only work for specific people, while Internal sharing means links work for anyone in the organization. Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link, reducing the risk of unintentionally granting edit privileges.", "executiveText": "Configures SharePoint default sharing links to implement the principle of least privilege for document sharing. This security measure reduces the risk of accidental data modification while maintaining collaboration functionality, requiring users to explicitly select Edit permissions when necessary. The sharing type setting controls whether links are restricted to specific recipients or available to the entire organization. This reduces the risk of accidental data exposure through link sharing.", @@ -4927,11 +4740,7 @@ "CISA (MS.AAD.3.1v1)", "NIST CSF 2.0 (PR.IR-01)" ], - "appliesToTest": [ - "CIS_7_2_1", - "ZTNA21776", - "ZTNA21797" - ], + "appliesToTest": ["CIS_7_2_1", "ZTNA21776", "ZTNA21797"], "helpText": "Disables the ability to authenticate with SharePoint using legacy authentication methods. Any applications that use legacy authentication will need to be updated to use modern authentication.", "docsDescription": "Disables the ability for users and applications to access SharePoint via legacy basic authentication. This will likely not have any user impact, but will block systems/applications depending on basic auth or the SharePointOnlineCredentials class.", "executiveText": "Disables outdated authentication methods for SharePoint access, forcing applications and users to use modern, more secure authentication protocols. This significantly improves security by eliminating vulnerable authentication pathways while requiring updates to older applications.", @@ -4960,12 +4769,7 @@ "CISA (MS.AAD.14.1v1)", "CISA (MS.SPO.1.1v1)" ], - "appliesToTest": [ - "CIS_7_2_3", - "CIS_7_2_4", - "ZTNA21803", - "ZTNA21804" - ], + "appliesToTest": ["CIS_7_2_3", "CIS_7_2_4", "ZTNA21803", "ZTNA21804"], "helpText": "Sets the default sharing level for OneDrive and SharePoint. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Defines the organization's default policy for sharing files and folders in SharePoint and OneDrive, balancing collaboration needs with security requirements. This fundamental setting determines whether employees can share with external users, anonymous links, or only internal colleagues.", "addedComponent": [ @@ -5012,16 +4816,8 @@ { "name": "standards.DisableReshare", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.2.5)", - "CISA (MS.AAD.14.2v1)", - "CISA (MS.SPO.1.2v1)" - ], - "appliesToTest": [ - "CIS_7_2_5", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 6.0.1 (7.2.5)", "CISA (MS.AAD.14.2v1)", "CISA (MS.SPO.1.2v1)"], + "appliesToTest": ["CIS_7_2_5", "ZTNA21803", "ZTNA21804"], "helpText": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access", "docsDescription": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Prevents external users from sharing company documents with additional people, maintaining control over document distribution and preventing unauthorized access expansion. This security measure ensures that external sharing remains within intended boundaries set by internal employees.", @@ -5118,15 +4914,8 @@ { "name": "standards.unmanagedSync", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.3.2)", - "CISA (MS.SPO.2.1v1)", - "NIST CSF 2.0 (PR.AA-05)" - ], - "appliesToTest": [ - "CIS_7_3_2", - "ZTNA24824" - ], + "tag": ["CIS M365 6.0.1 (7.3.2)", "CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"], + "appliesToTest": ["CIS_7_3_2", "ZTNA24824"], "helpText": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect.", "docsDescription": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect. 0 = Allow Access, 1 = Allow limited, web-only access, 2 = Block access. All information about this can be found in Microsofts documentation [here.](https://learn.microsoft.com/en-us/sharepoint/control-access-from-unmanaged-devices)", "executiveText": "Restricts access to company files from personal or unmanaged devices, ensuring corporate data can only be accessed from properly secured and monitored devices. This critical security control prevents data leaks while allowing controlled access through web browsers when necessary.", @@ -5155,16 +4944,8 @@ { "name": "standards.sharingDomainRestriction", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.2.6)", - "CISA (MS.AAD.14.3v1)", - "CISA (MS.SPO.1.3v1)" - ], - "appliesToTest": [ - "CIS_7_2_6", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 6.0.1 (7.2.6)", "CISA (MS.AAD.14.3v1)", "CISA (MS.SPO.1.3v1)"], + "appliesToTest": ["CIS_7_2_6", "ZTNA21803", "ZTNA21804"], "helpText": "Restricts sharing to only users with the specified domain. This is useful for organizations that only want to share with their own domain.", "executiveText": "Controls which external domains employees can share files with, enabling secure collaboration with trusted partners while blocking sharing with unauthorized organizations. This targeted approach maintains necessary business relationships while preventing data exposure to unknown entities.", "addedComponent": [ @@ -5523,10 +5304,7 @@ "name": "standards.TeamsExternalAccessPolicy", "cat": "Teams Standards", "tag": ["CIS M365 6.0.1 (8.2.1)", "CIS M365 6.0.1 (8.2.2)"], - "appliesToTest": [ - "CIS_8_2_1", - "CIS_8_2_2" - ], + "appliesToTest": ["CIS_8_2_1", "CIS_8_2_2"], "helpText": "Sets the properties of the Global external access policy.", "docsDescription": "Sets the properties of the Global external access policy. External access policies determine whether or not your users can: 1) communicate with users who have Session Initiation Protocol (SIP) accounts with a federated organization; 2) communicate with users who are using custom applications built with Azure Communication Services; 3) access Skype for Business Server over the Internet, without having to log on to your internal network; 4) communicate with users who have SIP accounts with a public instant messaging (IM) provider such as Skype; and, 5) communicate with people who are using Teams with an account that's not managed by an organization.", "executiveText": "Defines the organization's policy for communicating with external users through Teams, including other organizations, Skype users, and unmanaged accounts. This fundamental setting determines the scope of external collaboration while maintaining security boundaries for business communications.", @@ -7289,5 +7067,34 @@ "addedDate": "2026-05-06", "powershellEquivalent": "Graph API PATCH https://graph.microsoft.com/beta/policies/b2bManagementPolicies/default", "recommendedBy": ["CIS"] + }, + { + "name": "standards.IntuneAppTemplateDeploy", + "cat": "Intune Standards", + "tag": [], + "helpText": "Deploys selected Intune application templates to the tenant. Supports WinGet/Store apps, Office apps, Chocolatey apps, Win32 script apps, and MSP apps.", + "docsDescription": "Uses CIPP Intune Application Templates to deploy applications across tenants as a standard. Each template can contain multiple applications of different types which will be queued for deployment.", + "executiveText": "Automatically deploys approved Intune applications across all managed tenants, ensuring consistent software availability and reducing manual deployment overhead. Supports WinGet, Office, Chocolatey, Win32, and MSP application types.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Application Templates", + "name": "standards.IntuneAppTemplateDeploy.templateIds", + "api": { + "url": "/api/ListAppTemplates", + "labelField": "displayName", + "valueField": "GUID", + "queryKey": "StdIntuneAppTemplateList" + } + } + ], + "label": "Deploy Intune Application Template", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-23", + "powershellEquivalent": "Graph API - /deviceAppManagement/mobileApps", + "recommendedBy": [] } ] From 28cafc931d46c705b3481a1e5bd185b39ca8879d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 24 May 2026 23:06:16 +0200 Subject: [PATCH 078/164] added third party notice --- src/pages/cipp/integrations/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/cipp/integrations/index.js b/src/pages/cipp/integrations/index.js index 6d3f24f86ee4..ceb37a1cd0cb 100644 --- a/src/pages/cipp/integrations/index.js +++ b/src/pages/cipp/integrations/index.js @@ -141,7 +141,7 @@ const Page = () => { height: 8, }} /> - Coming Soon + Coming Soon through third-Party ) : ( <> From 30455f273d7cfcba5add1012bc260bb789f7c405 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 24 May 2026 23:06:35 +0200 Subject: [PATCH 079/164] third party --- src/pages/cipp/integrations/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/cipp/integrations/index.js b/src/pages/cipp/integrations/index.js index ceb37a1cd0cb..cc3067675bab 100644 --- a/src/pages/cipp/integrations/index.js +++ b/src/pages/cipp/integrations/index.js @@ -141,7 +141,7 @@ const Page = () => { height: 8, }} /> - Coming Soon through third-Party + Coming Soon through Third-Party ) : ( <> From d4f458a15b67d7c8cdd8b8095d3a638a040a9dd4 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 24 May 2026 23:07:33 +0200 Subject: [PATCH 080/164] Third party text --- src/pages/cipp/integrations/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/cipp/integrations/index.js b/src/pages/cipp/integrations/index.js index cc3067675bab..a60530323c46 100644 --- a/src/pages/cipp/integrations/index.js +++ b/src/pages/cipp/integrations/index.js @@ -141,7 +141,7 @@ const Page = () => { height: 8, }} /> - Coming Soon through Third-Party + Coming Soon Through Third-Party ) : ( <> From ee0ab2abe3341bd2f0270391f36e781f6f4ef8d2 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 25 May 2026 00:13:35 +0200 Subject: [PATCH 081/164] add extendedValues --- src/components/CippFormPages/CippAddEditUser.jsx | 9 +++++++++ src/pages/tenant/manage/user-defaults.js | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 1e4ac2353fd8..ad578556a517 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -351,6 +351,15 @@ const CippAddEditUser = (props) => { } } } + + // Populate custom user attributes from template + if (template.defaultAttributes) { + Object.entries(template.defaultAttributes).forEach(([key, attr]) => { + if (attr?.Value) { + setFieldIfEmpty(`defaultAttributes.${key}.Value`, attr.Value) + } + }) + } } }, [watchedFields.userTemplate, formType]) diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 25fd4b63362d..7f512cae7763 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -194,6 +194,13 @@ const Page = () => { name: 'businessPhones[0]', type: 'textField', }, + ...(userSettings?.userAttributes + ?.filter((attribute) => attribute.value !== 'sponsor') + .map((attribute) => ({ + label: attribute.label, + name: `defaultAttributes.${attribute.label}.Value`, + type: 'textField', + })) || []), ] const actions = [ @@ -241,6 +248,9 @@ const Page = () => { 'department', 'mobilePhone', 'businessPhones', + ...(userSettings?.userAttributes + ?.filter((attribute) => attribute.value !== 'sponsor') + .map((attribute) => `defaultAttributes.${attribute.label}.Value`) || []), ], actions: actions, } From 17bf1f8dbbd6fe16a63fa268c60dfa8fe8a0c053 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 25 May 2026 00:14:08 +0200 Subject: [PATCH 082/164] fixes #5995 --- src/pages/tenant/manage/user-defaults.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 7f512cae7763..8eb4f2592f61 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -91,7 +91,8 @@ const Page = () => { labelField: 'id', valueField: 'id', queryKey: `ListGraphRequest-domains-${userSettings.currentTenant}`, - dataFilter: (options) => options.filter((option) => option?.addedFields?.isVerified === true), // Only include verified domains + dataFilter: (options) => + options.filter((option) => option?.addedFields?.isVerified === true), // Only include verified domains }, multiple: false, creatable: false, From 8097e6ede3991b61e65b03bd21496dc55c96bc33 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 25 May 2026 01:34:03 +0200 Subject: [PATCH 083/164] FIDO2 profile standards --- src/data/standards.json | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index 266bb8af19cd..0322daffd1a3 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -7096,5 +7096,64 @@ "addedDate": "2026-05-23", "powershellEquivalent": "Graph API - /deviceAppManagement/mobileApps", "recommendedBy": [] + }, + { + "name": "standards.FIDO2PasskeyProfiles", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures FIDO2 passkey profiles including AAGUID allowlists, attestation enforcement, and passkey types for the tenant.", + "docsDescription": "Manages FIDO2 passkey profiles on the tenant authentication methods policy. Allows defining passkey profiles that control which authenticators (hardware keys, password managers, Microsoft Authenticator) are permitted via AAGUID allowlists, whether attestation is enforced, and which passkey types (device-bound, synced, or both) are allowed. This enables MSPs to centrally deploy phishing-resistant MFA configurations across tenants.", + "executiveText": "Configures passkey (FIDO2) profiles that control which authenticators users can register for phishing-resistant MFA. Supports allowlisting specific hardware keys (e.g., YubiKey models), password managers (e.g., 1Password), and Microsoft Authenticator by AAGUID, with control over attestation enforcement and passkey types.", + "addedComponent": [ + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.PasskeyTypes", + "label": "Allowed Passkey Types", + "options": [ + { "label": "Device-bound only", "value": "deviceBound" }, + { "label": "Synced only", "value": "synced" }, + { "label": "Both device-bound and synced", "value": "deviceBound,synced" } + ], + "required": true + }, + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.AttestationEnforcement", + "label": "Attestation Enforcement", + "options": [ + { "label": "Disabled (required for synced passkeys)", "value": "disabled" }, + { "label": "Registration only", "value": "registrationOnly" } + ], + "required": true + }, + { + "type": "switch", + "name": "standards.FIDO2PasskeyProfiles.EnforceKeyRestrictions", + "label": "Enforce AAGUID Key Restrictions" + }, + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.EnforcementType", + "label": "Key Restriction Type", + "options": [ + { "label": "Allow listed AAGUIDs only", "value": "allow" }, + { "label": "Block listed AAGUIDs", "value": "block" } + ] + }, + { + "type": "textField", + "name": "standards.FIDO2PasskeyProfiles.AAGUIDs", + "label": "AAGUIDs (comma-separated list of authenticator AAGUIDs)" + } + ], + "label": "Configure FIDO2 Passkey Profile", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-25", + "powershellEquivalent": "Graph API PATCH /policies/authenticationMethodsPolicy/authenticationMethodConfigurations/fido2", + "recommendedBy": ["CIPP"] } ] From 389babe3e5641be1363afed31b78eddd1a0e3949 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 25 May 2026 01:58:14 +0200 Subject: [PATCH 084/164] add global var showing --- .../CippComponents/CippCustomVariables.jsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/CippComponents/CippCustomVariables.jsx b/src/components/CippComponents/CippCustomVariables.jsx index 69b5975d1777..d53b789428ae 100644 --- a/src/components/CippComponents/CippCustomVariables.jsx +++ b/src/components/CippComponents/CippCustomVariables.jsx @@ -60,6 +60,7 @@ const CippCustomVariables = ({ id }) => { confirmText: "Update the custom variable '[RowKey]'?", hideBulk: true, setDefaultValues: true, + condition: (row) => row.Scope !== "Global" || id === "AllTenants", fields: [ { type: "textField", @@ -74,7 +75,6 @@ const CippCustomVariables = ({ id }) => { type: "textField", name: "Value", label: "Value", - disableVariables: true, placeholder: "Enter the value for the custom variable.", required: true, }, @@ -99,6 +99,7 @@ const CippCustomVariables = ({ id }) => { label: "Delete", icon: , confirmText: "Are you sure you want to delete [RowKey]?", + condition: (row) => row.Scope !== "Global" || id === "AllTenants", type: "POST", url: "/api/ExecCippReplacemap", data: { @@ -127,10 +128,17 @@ const CippCustomVariables = ({ id }) => { title={id === "AllTenants" ? "Global Variables" : "Custom Variables"} actions={actions} api={{ - url: `/api/ExecCippReplacemap?Action=List&tenantId=${id}`, + url: + id === "AllTenants" + ? `/api/ExecCippReplacemap?Action=List&tenantId=${id}` + : `/api/ExecCippReplacemap?Action=List&tenantId=${id}&includeGlobal=true`, dataKey: "Results", }} - simpleColumns={["RowKey", "Value", "Description"]} + simpleColumns={ + id === "AllTenants" + ? ["RowKey", "Value", "Description"] + : ["RowKey", "Value", "Description", "Scope"] + } cardButton={ - diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js index 444ee7dcd4ac..dd45430feee4 100644 --- a/src/layouts/account-popover.js +++ b/src/layouts/account-popover.js @@ -65,7 +65,7 @@ export const AccountPopover = (props) => { // delete query cache and persisted data queryClient.clear(); - router.push("/.auth/logout?post_logout_redirect_uri=" + encodeURIComponent(paths.index)); + router.push("/.auth/logout?prompt=select_account&post_logout_redirect_uri=" + encodeURIComponent(paths.index)); } catch (err) { console.error(err); console.log(orgData); diff --git a/src/layouts/index.js b/src/layouts/index.js index 07f2f152a562..f3c178556ff3 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -341,7 +341,7 @@ export const Layout = (props) => { - + {!setupCompleted && ( From 16b4503f014b4d5b99ff0e49485dcc97544e3cee Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 25 May 2026 11:29:56 +0800 Subject: [PATCH 088/164] login/out testing --- src/layouts/account-popover.js | 2 +- staticwebapp.config.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js index dd45430feee4..444ee7dcd4ac 100644 --- a/src/layouts/account-popover.js +++ b/src/layouts/account-popover.js @@ -65,7 +65,7 @@ export const AccountPopover = (props) => { // delete query cache and persisted data queryClient.clear(); - router.push("/.auth/logout?prompt=select_account&post_logout_redirect_uri=" + encodeURIComponent(paths.index)); + router.push("/.auth/logout?post_logout_redirect_uri=" + encodeURIComponent(paths.index)); } catch (err) { console.error(err); console.log(orgData); diff --git a/staticwebapp.config.json b/staticwebapp.config.json index 1f57342751ca..4c6b5d68b840 100644 --- a/staticwebapp.config.json +++ b/staticwebapp.config.json @@ -20,7 +20,7 @@ }, { "route": "/login", - "rewrite": "/.auth/login/aad" + "redirect": "/.auth/login/aad?prompt=select_account" }, { "route": "/.auth/login/twitter", @@ -70,7 +70,7 @@ }, "responseOverrides": { "401": { - "redirect": "/.auth/login/aad?post_login_redirect_uri=.referrer", + "redirect": "/.auth/login/aad?prompt=select_account&post_login_redirect_uri=.referrer", "statusCode": 302, "exclude": ["/assets/illustrations/*.{png,jpg,gif}", "/css/*"] }, From c1c5693c09ef99c196b80d17b4b8aeb61f1d9852 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Mon, 25 May 2026 11:49:27 +0200 Subject: [PATCH 089/164] feat: add admin role member removal functionality --- .../identity/administration/roles/index.js | 77 ++- .../administration/users/user/index.jsx | 588 ++++++++++-------- 2 files changed, 379 insertions(+), 286 deletions(-) diff --git a/src/pages/identity/administration/roles/index.js b/src/pages/identity/administration/roles/index.js index f09fb8a01388..27b95f7ed9c9 100644 --- a/src/pages/identity/administration/roles/index.js +++ b/src/pages/identity/administration/roles/index.js @@ -1,24 +1,71 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { usePermissions } from '../../../../hooks/use-permissions' +import { PersonRemove } from '@mui/icons-material' + +const RemoveRoleMembersForm = ({ formHook, row }) => { + const memberOptions = (row?.Members ?? []).map((member) => ({ + label: member.userPrincipalName + ? `${member.displayName} (${member.userPrincipalName})` + : member.displayName, + value: member.id, + addedFields: { + displayName: member.displayName, + userPrincipalName: member.userPrincipalName, + }, + })) + + return ( + + ) +} const Page = () => { - const pageTitle = "Roles"; + const pageTitle = 'Roles' + const { checkPermissions } = usePermissions() + const canWriteRole = checkPermissions(['Identity.Role.ReadWrite']) - const actions = []; + const actions = [ + { + label: 'Remove Members', + type: 'POST', + icon: , + url: '/api/ExecRemoveAdminRole', + children: ({ formHook, row }) => , + data: { + RoleId: 'Id', + RoleName: 'DisplayName', + }, + confirmText: 'Select the members to remove from [DisplayName].', + allowResubmit: true, + hideBulk: true, + condition: (row) => canWriteRole && (row?.Members ?? []).length > 0, + }, + ] const offCanvas = { extendedInfoFields: [ - "DisplayName", // Role Group Name - "Members", // Member Names + 'DisplayName', // Role Group Name + 'Members', // Member Names ], actions: actions, - }; + } const columns = [ - "DisplayName", // Role Name - "Description", // Description - "Members", // Members - ]; + 'DisplayName', // Role Name + 'Description', // Description + 'Members', // Members + ] return ( { offCanvas={offCanvas} simpleColumns={columns} /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page diff --git a/src/pages/identity/administration/users/user/index.jsx b/src/pages/identity/administration/users/user/index.jsx index 95adbe3d4fed..cce0e8f472fd 100644 --- a/src/pages/identity/administration/users/user/index.jsx +++ b/src/pages/identity/administration/users/user/index.jsx @@ -1,33 +1,43 @@ -import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; -import { useSettings } from "../../../../../hooks/use-settings"; -import { useRouter } from "next/router"; -import { ApiGetCall, ApiPostCall } from "../../../../../api/ApiCall"; -import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton"; -import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon"; -import { AdminPanelSettings, Check, Group, Mail, Fingerprint, Launch, Devices } from "@mui/icons-material"; -import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; -import tabOptions from "./tabOptions"; -import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard"; -import { Box, Stack } from "@mui/system"; -import { Grid } from "@mui/system"; -import { CippUserInfoCard } from "../../../../../components/CippCards/CippUserInfoCard"; -import { SvgIcon, Typography } from "@mui/material"; -import { CippBannerListCard } from "../../../../../components/CippCards/CippBannerListCard"; -import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; -import { useEffect, useState } from "react"; -import { useCippUserActions } from "../../../../../components/CippComponents/CippUserActions"; -import { EyeIcon, PencilIcon } from "@heroicons/react/24/outline"; -import { CippDataTable } from "../../../../../components/CippTable/CippDataTable"; -import dynamic from "next/dynamic"; -const CippMap = dynamic(() => import("../../../../../components/CippComponents/CippMap"), { +import { Layout as DashboardLayout } from '../../../../../layouts/index.js' +import { useSettings } from '../../../../../hooks/use-settings' +import { useRouter } from 'next/router' +import { ApiGetCall, ApiPostCall } from '../../../../../api/ApiCall' +import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSkeleton' +import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' +import { + AdminPanelSettings, + Check, + Group, + Mail, + Fingerprint, + Launch, + Devices, + PersonRemove, +} from '@mui/icons-material' +import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import tabOptions from './tabOptions' +import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' +import { Box, Stack } from '@mui/system' +import { Grid } from '@mui/system' +import { CippUserInfoCard } from '../../../../../components/CippCards/CippUserInfoCard' +import { SvgIcon, Typography } from '@mui/material' +import { CippBannerListCard } from '../../../../../components/CippCards/CippBannerListCard' +import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo' +import { useEffect, useState } from 'react' +import { useCippUserActions } from '../../../../../components/CippComponents/CippUserActions' +import { EyeIcon, PencilIcon } from '@heroicons/react/24/outline' +import { CippDataTable } from '../../../../../components/CippTable/CippDataTable' +import dynamic from 'next/dynamic' +const CippMap = dynamic(() => import('../../../../../components/CippComponents/CippMap'), { ssr: false, -}); +}) -import { Button, Dialog, DialogTitle, DialogContent, IconButton } from "@mui/material"; -import { Close } from "@mui/icons-material"; -import { CippPropertyList } from "../../../../../components/CippComponents/CippPropertyList"; -import { CippCodeBlock } from "../../../../../components/CippComponents/CippCodeBlock"; -import { CippHead } from "../../../../../components/CippComponents/CippHead"; +import { Button, Dialog, DialogTitle, DialogContent, IconButton } from '@mui/material' +import { Close } from '@mui/icons-material' +import { CippPropertyList } from '../../../../../components/CippComponents/CippPropertyList' +import { CippCodeBlock } from '../../../../../components/CippComponents/CippCodeBlock' +import { CippHead } from '../../../../../components/CippComponents/CippHead' +import { usePermissions } from '../../../../../hooks/use-permissions' const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { return ( @@ -37,7 +47,7 @@ const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { @@ -47,16 +57,16 @@ const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { noCard={true} title="Sign-In Logs" simpleColumns={[ - "createdDateTime", - "status", - "ipAddress", - "clientAppUsed", - "resourceDisplayName", - "status.errorCode", - "location", + 'createdDateTime', + 'status', + 'ipAddress', + 'clientAppUsed', + 'resourceDisplayName', + 'status.errorCode', + 'location', ]} api={{ - url: "/api/ListUserSigninLogs", + url: '/api/ListUserSigninLogs', data: { UserId: userId, tenantFilter: tenantFilter, @@ -67,22 +77,24 @@ const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { /> - ); -}; + ) +} const Page = () => { - const userSettingsDefaults = useSettings(); - const router = useRouter(); - const { userId } = router.query; - const [waiting, setWaiting] = useState(false); - const [signInLogsDialogOpen, setSignInLogsDialogOpen] = useState(false); - const userActions = useCippUserActions(); + const userSettingsDefaults = useSettings() + const router = useRouter() + const { userId } = router.query + const [waiting, setWaiting] = useState(false) + const [signInLogsDialogOpen, setSignInLogsDialogOpen] = useState(false) + const userActions = useCippUserActions() + const { checkPermissions } = usePermissions() + const canWriteRole = checkPermissions(['Identity.Role.ReadWrite']) useEffect(() => { if (userId) { - setWaiting(true); + setWaiting(true) } - }, [userId]); + }, [userId]) const userRequest = ApiGetCall({ url: `/api/ListUsers?UserId=${userId}&tenantFilter=${ @@ -90,70 +102,75 @@ const Page = () => { }`, queryKey: `ListUsers-${userId}`, waiting: waiting, - }); + }) const userBulkRequest = ApiPostCall({ urlFromData: true, - }); + }) function refreshFunction() { - const userPrincipalName = userRequest.data?.[0]?.userPrincipalName; + const userPrincipalName = userRequest.data?.[0]?.userPrincipalName const requests = [ { - id: "userMemberOf", + id: 'userMemberOf', url: `/users/${userId}/memberOf`, - method: "GET", + method: 'GET', }, { - id: "mfaDevices", + id: 'mfaDevices', url: `/users/${userId}/authentication/methods?$top=99`, - method: "GET", + method: 'GET', }, { - id: "signInLogs", + id: 'signInLogs', url: `/auditLogs/signIns?$filter=(userId eq '${userId}')&$top=1`, - method: "GET", + method: 'GET', }, - ]; + ] // Only add managedDevices request if we have the userPrincipalName if (userPrincipalName) { requests.push({ - id: "managedDevices", + id: 'managedDevices', url: `/deviceManagement/managedDevices?$filter=userPrincipalName eq '${userPrincipalName}'`, - method: "GET", - }); + method: 'GET', + }) } userBulkRequest.mutate({ - url: "/api/ListGraphBulkRequest", + url: '/api/ListGraphBulkRequest', data: { Requests: requests, tenantFilter: userSettingsDefaults.currentTenant, - noPaginateIds: ["signInLogs"], + noPaginateIds: ['signInLogs'], }, - }); + }) } useEffect(() => { - if (userId && userSettingsDefaults.currentTenant && userRequest.isSuccess && !userBulkRequest.isSuccess) { - refreshFunction(); + if ( + userId && + userSettingsDefaults.currentTenant && + userRequest.isSuccess && + !userBulkRequest.isSuccess + ) { + refreshFunction() } - }, [userId, userSettingsDefaults.currentTenant, userRequest.isSuccess, userBulkRequest.isSuccess]); + }, [userId, userSettingsDefaults.currentTenant, userRequest.isSuccess, userBulkRequest.isSuccess]) - const bulkData = userBulkRequest?.data?.data ?? []; - const signInLogsData = bulkData?.find((item) => item.id === "signInLogs"); - const userMemberOfData = bulkData?.find((item) => item.id === "userMemberOf"); - const mfaDevicesData = bulkData?.find((item) => item.id === "mfaDevices"); - const managedDevicesData = bulkData?.find((item) => item.id === "managedDevices"); + const bulkData = userBulkRequest?.data?.data ?? [] + const signInLogsData = bulkData?.find((item) => item.id === 'signInLogs') + const userMemberOfData = bulkData?.find((item) => item.id === 'userMemberOf') + const mfaDevicesData = bulkData?.find((item) => item.id === 'mfaDevices') + const managedDevicesData = bulkData?.find((item) => item.id === 'managedDevices') - const signInLogs = signInLogsData?.body?.value || []; - const userMemberOf = userMemberOfData?.body?.value || []; - const mfaDevices = mfaDevicesData?.body?.value || []; - const managedDevices = managedDevicesData?.body?.value || []; + const signInLogs = signInLogsData?.body?.value || [] + const userMemberOf = userMemberOfData?.body?.value || [] + const mfaDevices = mfaDevicesData?.body?.value || [] + const managedDevices = managedDevicesData?.body?.value || [] // Set the title and subtitle for the layout - const title = userRequest.isSuccess ? userRequest.data?.[0]?.displayName : "Loading..."; + const title = userRequest.isSuccess ? userRequest.data?.[0]?.displayName : 'Loading...' const subtitle = userRequest.isSuccess ? [ @@ -174,7 +191,7 @@ const Page = () => { ), }, { - icon: , + icon: , text: ( + + + ); +}; + +export default CippTutorialDialog; diff --git a/src/contexts/tutorial-context.js b/src/contexts/tutorial-context.js new file mode 100644 index 000000000000..954b641d8b0b --- /dev/null +++ b/src/contexts/tutorial-context.js @@ -0,0 +1,169 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { driver } from "driver.js"; +import { useRouter } from "next/router"; + +const STORAGE_KEY = "cipp.tutorials.completed"; + +const getCompletedTutorials = () => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +}; + +const storeCompletedTutorial = (id) => { + try { + const completed = getCompletedTutorials(); + if (!completed.includes(id)) { + completed.push(id); + localStorage.setItem(STORAGE_KEY, JSON.stringify(completed)); + } + } catch { + // ignore + } +}; + +const resetCompletedTutorials = () => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } +}; + +const TutorialContext = createContext({ + tutorials: [], + activeTutorial: null, + completedIds: [], + startTutorial: () => {}, + resetProgress: () => {}, + getTutorialsForPage: () => [], +}); + +// Load all tutorial JSON files from the data/tutorials folder at build time +const loadTutorials = () => { + const context = require.context("../data/tutorials", false, /\.json$/); + return context.keys().map((key) => { + const tutorial = context(key); + return tutorial.default || tutorial; + }); +}; + +export const TutorialProvider = ({ children }) => { + const [tutorials] = useState(() => loadTutorials()); + const [completedIds, setCompletedIds] = useState([]); + const [activeTutorial, setActiveTutorial] = useState(null); + const driverRef = useRef(null); + const router = useRouter(); + + useEffect(() => { + setCompletedIds(getCompletedTutorials()); + }, []); + + // Launch tutorial from ?tutorial=$id query param + useEffect(() => { + if (!router.isReady || activeTutorial) return; + const tutorialId = router.query.tutorial; + if (!tutorialId) return; + + const match = tutorials.find((t) => t.id === tutorialId); + if (!match) return; + + // Strip the query param so it doesn't re-trigger + const { tutorial: _, ...rest } = router.query; + router.replace({ pathname: router.pathname, query: rest }, undefined, { shallow: true }); + + // Delay to let the page fully render + setTimeout(() => runDriver(match), 600); + }, [router.isReady, router.query.tutorial, tutorials]); + + // Cleanup driver on unmount or route change + useEffect(() => { + return () => { + if (driverRef.current) { + driverRef.current.destroy(); + driverRef.current = null; + } + }; + }, []); + + const startTutorial = useCallback( + (tutorial) => { + if (driverRef.current) { + driverRef.current.destroy(); + } + + // If tutorial specifies pages and we're not on any of them, navigate first + if (tutorial.pages?.length && !tutorial.pages.includes(router.pathname)) { + router.push(tutorial.pages[0]).then(() => { + // Small delay to let the page render before starting the tour + setTimeout(() => runDriver(tutorial), 500); + }); + return; + } + + runDriver(tutorial); + }, + [router] + ); + + const runDriver = useCallback((tutorial) => { + setActiveTutorial(tutorial); + + const driverObj = driver({ + showProgress: true, + animate: true, + allowClose: true, + overlayColor: "rgba(0, 0, 0, 0.6)", + stagePadding: 8, + stageRadius: 8, + popoverClass: "cipp-tutorial-popover", + nextBtnText: "Next →", + prevBtnText: "← Back", + doneBtnText: "Done ✓", + progressText: "{{current}} of {{total}}", + steps: tutorial.steps, + onDestroyed: () => { + storeCompletedTutorial(tutorial.id); + setCompletedIds(getCompletedTutorials()); + setActiveTutorial(null); + driverRef.current = null; + }, + }); + + driverRef.current = driverObj; + driverObj.drive(); + }, []); + + const resetProgress = useCallback(() => { + resetCompletedTutorials(); + setCompletedIds([]); + }, []); + + const getTutorialsForPage = useCallback( + (pathname) => { + return tutorials.filter( + (t) => !t.pages || t.pages.length === 0 || t.pages.includes(pathname) + ); + }, + [tutorials] + ); + + const value = useMemo( + () => ({ + tutorials, + activeTutorial, + completedIds, + startTutorial, + resetProgress, + getTutorialsForPage, + }), + [tutorials, activeTutorial, completedIds, startTutorial, resetProgress, getTutorialsForPage] + ); + + return {children}; +}; + +export const useTutorials = () => useContext(TutorialContext); diff --git a/src/data/tutorials/dashboard-overview.json b/src/data/tutorials/dashboard-overview.json new file mode 100644 index 000000000000..df2aeca19615 --- /dev/null +++ b/src/data/tutorials/dashboard-overview.json @@ -0,0 +1,111 @@ +{ + "id": "dashboard-overview", + "title": "Dashboard Overview", + "description": "A guided tour of the CIPP Dashboard — learn what each card, chart, and control does.", + "category": "General", + "pages": ["/dashboardv2"], + "steps": [ + { + "popover": { + "title": "Welcome to the Dashboard 👋", + "description": "This tour will walk you through every section of the CIPP Dashboard so you know exactly where to find the information you need." + } + }, + { + "element": "[data-tutorial='tenant-selector']", + "popover": { + "title": "Select a Tenant", + "description": "Start here. Pick the customer tenant you want to inspect. The entire dashboard updates to show data for the selected tenant.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-portals']", + "popover": { + "title": "Quick Portal Access", + "description": "Jump straight into the Microsoft admin portals (M365, Exchange, Entra, Intune, Azure, etc.) for the selected tenant — no need to look up URLs.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-test-suite']", + "popover": { + "title": "Test Suite Selector", + "description": "Choose which test suite to run against the tenant. You can create custom test suites, refresh results, or edit existing ones from here.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-tenant-info']", + "popover": { + "title": "Tenant Information", + "description": "Quick-reference card showing the tenant's display name, tenant ID, and primary domain. Click the chips to copy values to your clipboard.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-tenant-metrics']", + "popover": { + "title": "Tenant Metrics", + "description": "At-a-glance counts for Users, Guests, Groups, Service Principals, Devices, and Managed Devices. Click any metric to drill into the full list.", + "side": "bottom", + "align": "center" + } + }, + { + "element": "[data-tutorial='dashboard-assessment']", + "popover": { + "title": "Assessment Results", + "description": "A summary of the selected test suite results broken down by category (Identity, Devices, Custom). Green = passed, Red = failed, Orange = skipped.", + "side": "bottom", + "align": "end" + } + }, + { + "element": "[data-tutorial='dashboard-secure-score']", + "popover": { + "title": "Secure Score Trend", + "description": "A line chart tracking the tenant's Microsoft Secure Score over time. The reference line shows the maximum possible score, so you can gauge progress.", + "side": "right", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-mfa']", + "popover": { + "title": "User Authentication (MFA)", + "description": "This Sankey diagram shows how many enabled users are MFA-registered vs. not, and what enforcement method protects them (Conditional Access, Security Defaults, Per-user MFA, or none). Click any node to jump to the MFA report.", + "side": "left", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-auth-methods']", + "popover": { + "title": "Auth Methods Breakdown", + "description": "See how users authenticate — single-factor vs. multi-factor, phishable vs. phish-resistant — and which methods (Phone, Authenticator, Passkey, WHfB) they use. Click a node to filter the MFA report.", + "side": "right", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-licenses']", + "popover": { + "title": "License Overview", + "description": "Shows the top licenses in the tenant with assigned vs. available counts. Use this to spot unused or over-provisioned licenses at a glance.", + "side": "left", + "align": "start" + } + }, + { + "popover": { + "title": "You're all set! 🎉", + "description": "That covers the Dashboard. Explore the other tabs at the top for more views, or check the sidebar for all CIPP modules. You can replay this tour anytime from the Tutorials menu." + } + } + ] +} diff --git a/src/data/tutorials/getting-started.json b/src/data/tutorials/getting-started.json new file mode 100644 index 000000000000..8e2c2291350a --- /dev/null +++ b/src/data/tutorials/getting-started.json @@ -0,0 +1,48 @@ +{ + "id": "getting-started", + "title": "Getting Started with CIPP", + "description": "Learn the basics of navigating CIPP and managing your tenants.", + "category": "General", + "pages": ["/"], + "steps": [ + { + "popover": { + "title": "Welcome to CIPP! 👋", + "description": "This quick tour will show you the key features of the CIPP dashboard. Let's get started!" + } + }, + { + "element": "[data-tutorial='tenant-selector']", + "popover": { + "title": "Tenant Selector", + "description": "Use the tenant selector to switch between your managed tenants. You can search by name or select 'All Tenants' for a global view.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='side-nav']", + "popover": { + "title": "Navigation Menu", + "description": "The sidebar gives you access to all CIPP modules — Identity, Endpoint, Security, Email, Teams & SharePoint, and more.", + "side": "right", + "align": "start" + } + }, + { + "element": "[data-tutorial='speed-dial']", + "popover": { + "title": "Quick Actions", + "description": "Use the help button in the bottom-right corner to report bugs, request features, join Discord, or access the documentation.", + "side": "left", + "align": "end" + } + }, + { + "popover": { + "title": "You're all set! 🎉", + "description": "You now know the basics. Explore the sidebar to discover all the tools CIPP offers. You can replay this tour anytime from the Tutorials menu." + } + } + ] +} diff --git a/src/data/tutorials/tenant-management.json b/src/data/tutorials/tenant-management.json new file mode 100644 index 000000000000..0492fd8fdac5 --- /dev/null +++ b/src/data/tutorials/tenant-management.json @@ -0,0 +1,39 @@ +{ + "id": "tenant-management", + "title": "Managing Tenants", + "description": "Learn how to view tenant details, manage tenant settings, and navigate tenant-specific pages.", + "category": "Administration", + "pages": ["/tenant/administration/tenants"], + "steps": [ + { + "popover": { + "title": "Tenant Management", + "description": "This page shows all your managed tenants. Let's walk through the key features." + } + }, + { + "element": "[data-tutorial='breadcrumb-nav']", + "popover": { + "title": "Breadcrumb Navigation", + "description": "Use the breadcrumb trail to see where you are and quickly navigate back to parent pages.", + "side": "bottom", + "align": "start" + } + }, + { + "element": ".MuiTableContainer-root", + "popover": { + "title": "Tenant List", + "description": "Here you'll find all your managed tenants. Click on any row to see detailed information about that tenant.", + "side": "top", + "align": "center" + } + }, + { + "popover": { + "title": "That's it!", + "description": "You now know how to manage your tenants. Check out other tutorials for more advanced features." + } + } + ] +} diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index 5b01ee107331..1dadfca01577 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -169,6 +169,7 @@ export const SideNav = (props) => { setHovered(true), diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 41d5f07e0f2f..2c7e0a2507ad 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -278,7 +278,9 @@ export const TopNav = (props) => { {!mdDown && ( - + + + )} {mdDown && ( diff --git a/src/pages/_app.js b/src/pages/_app.js index aa387f0417fa..74504f43b9fa 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -11,6 +11,8 @@ import { store } from '../store' import { createTheme } from '../theme' import { createEmotionCache } from '../utils/create-emotion-cache' import '../libs/nprogress' +import 'driver.js/dist/driver.css' +import '../styles/tutorial-overrides.css' import { PrivateRoute } from '../components/PrivateRoute' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useMediaPredicate } from 'react-media-hook' @@ -52,12 +54,15 @@ import { Gavel, ClearAll as ClearAllIcon, } from '@mui/icons-material' +import { School as TutorialIcon } from '@mui/icons-material' import { SvgIcon } from '@mui/material' import React, { useEffect, useState, useRef } from 'react' import { usePathname } from 'next/navigation' import { useRouter } from 'next/router' import { persistQueryClient } from '@tanstack/react-query-persist-client' import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' +import { TutorialProvider } from '../contexts/tutorial-context' +import CippTutorialDialog from '../components/CippComponents/CippTutorialDialog' const ReactQueryDevtoolsProduction = React.lazy(() => import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({ @@ -76,6 +81,7 @@ const App = (props) => { const pathname = usePathname() const route = useRouter() const [dateLocale, setDateLocale] = useState(enUS) + const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false) useEffect(() => { if (typeof window === 'undefined') return @@ -243,6 +249,12 @@ const App = (props) => { href: `https://docs.cipp.app/user-documentation${pathname}`, onClick: () => window.open(`https://docs.cipp.app/user-documentation${pathname}`, '_blank'), }, + { + id: 'tutorials', + icon: , + name: 'Tutorials', + onClick: () => setTutorialDialogOpen(true), + }, ] return ( @@ -275,9 +287,15 @@ const App = (props) => { - - {getLayout()} - + + + {getLayout()} + + setTutorialDialogOpen(false)} + /> + diff --git a/src/pages/dashboardv2/index.js b/src/pages/dashboardv2/index.js index 0ea9653e3680..dfd9d9a76528 100644 --- a/src/pages/dashboardv2/index.js +++ b/src/pages/dashboardv2/index.js @@ -194,8 +194,9 @@ const Page = () => { - + { )} - + @@ -319,12 +320,12 @@ const Page = () => { {/* Tenant Overview Section - 3 Column Layout */} {/* Column 1: Tenant Information */} - + {/* Column 2: Tenant Metrics - 2x3 Grid */} - + { {/* Column 3: Assessment Results */} - + { {/* Left Column */} - + - + { {/* Right Column */} - + - + Date: Mon, 25 May 2026 18:31:00 +0200 Subject: [PATCH 094/164] add tutorials to easy deployment of steps for Ashe. --- .../CippComponents/CippBreadcrumbNav.jsx | 514 +++++++++--------- .../CippComponents/CippSpeedDial.jsx | 156 +++--- .../CippComponents/CippTutorialDialog.jsx | 77 ++- src/contexts/tutorial-context.js | 142 +++-- src/layouts/top-nav.js | 6 +- src/pages/dashboardv2/index.js | 5 +- src/styles/tutorial-overrides.css | 2 +- 7 files changed, 455 insertions(+), 447 deletions(-) diff --git a/src/components/CippComponents/CippBreadcrumbNav.jsx b/src/components/CippComponents/CippBreadcrumbNav.jsx index 8e285d78e364..5ea88f3434cf 100644 --- a/src/components/CippComponents/CippBreadcrumbNav.jsx +++ b/src/components/CippComponents/CippBreadcrumbNav.jsx @@ -1,155 +1,155 @@ -import { useEffect, useState, useRef } from "react"; -import { useRouter } from "next/router"; -import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip } from "@mui/material"; -import { NavigateNext, History, AccountTree } from "@mui/icons-material"; -import { nativeMenuItems } from "../../layouts/config"; -import { useSettings } from "../../hooks/use-settings"; +import { useEffect, useState, useRef } from 'react' +import { useRouter } from 'next/router' +import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip } from '@mui/material' +import { NavigateNext, History, AccountTree } from '@mui/icons-material' +import { nativeMenuItems } from '../../layouts/config' +import { useSettings } from '../../hooks/use-settings' -const MAX_HISTORY_STORAGE = 20; // Maximum number of pages to keep in history -const MAX_BREADCRUMB_DISPLAY = 5; // Maximum number of breadcrumbs to display at once +const MAX_HISTORY_STORAGE = 20 // Maximum number of pages to keep in history +const MAX_BREADCRUMB_DISPLAY = 5 // Maximum number of breadcrumbs to display at once /** * Load all tabOptions.json files dynamically */ async function loadTabOptions() { const tabOptionPaths = [ - "/email/administration/exchange-retention", - "/cipp/custom-data", - "/cipp/advanced/super-admin", - "/endpoint/MEM/enrollment-profiles", - "/tenant/standards", - "/tenant/manage", - "/tenant/administration/applications", - "/tenant/administration/tenants", - "/tenant/administration/audit-logs", - "/identity/administration/users/user", - "/tenant/administration/securescore", - "/tenant/gdap-management", - "/tenant/gdap-management/relationships/relationship", - "/cipp/settings", - ]; - - const tabOptions = []; + '/email/administration/exchange-retention', + '/cipp/custom-data', + '/cipp/advanced/super-admin', + '/endpoint/MEM/enrollment-profiles', + '/tenant/standards', + '/tenant/manage', + '/tenant/administration/applications', + '/tenant/administration/tenants', + '/tenant/administration/audit-logs', + '/identity/administration/users/user', + '/tenant/administration/securescore', + '/tenant/gdap-management', + '/tenant/gdap-management/relationships/relationship', + '/cipp/settings', + ] + + const tabOptions = [] for (const basePath of tabOptionPaths) { try { - const module = await import(`../../pages${basePath}/tabOptions.json`); - const options = module.default || module; + const module = await import(`../../pages${basePath}/tabOptions.json`) + const options = module.default || module // Add each tab option with metadata options.forEach((option) => { tabOptions.push({ title: option.label, path: option.path, - type: "tab", + type: 'tab', basePath: basePath, - }); - }); + }) + }) } catch (error) { // Silently skip if file doesn't exist or can't be loaded } } - return tabOptions; + return tabOptions } export const CippBreadcrumbNav = () => { - const router = useRouter(); - const settings = useSettings(); - const [history, setHistory] = useState([]); - const [mode, setMode] = useState(settings.breadcrumbMode || "hierarchical"); - const [tabOptions, setTabOptions] = useState([]); - const lastRouteRef = useRef(null); - const titleCheckCountRef = useRef(0); - const titleCheckIntervalRef = useRef(null); + const router = useRouter() + const settings = useSettings() + const [history, setHistory] = useState([]) + const [mode, setMode] = useState(settings.breadcrumbMode || 'hierarchical') + const [tabOptions, setTabOptions] = useState([]) + const lastRouteRef = useRef(null) + const titleCheckCountRef = useRef(0) + const titleCheckIntervalRef = useRef(null) // Helper function to filter out unnecessary query parameters const getCleanQueryParams = (query) => { - const cleaned = { ...query }; + const cleaned = { ...query } // Remove tenantFilter if it's "AllTenants" or not explicitly needed - if (cleaned.tenantFilter === "AllTenants" || cleaned.tenantFilter === undefined) { - delete cleaned.tenantFilter; + if (cleaned.tenantFilter === 'AllTenants' || cleaned.tenantFilter === undefined) { + delete cleaned.tenantFilter } - return cleaned; - }; + return cleaned + } // Helper function to clean page titles const cleanPageTitle = (title) => { - if (!title) return title; + if (!title) return title // Remove AllTenants and any surrounding separators return title - .replace(/\s*-\s*AllTenants\s*/, "") - .replace(/AllTenants\s*-\s*/, "") - .replace(/AllTenants/, "") - .trim(); - }; + .replace(/\s*-\s*AllTenants\s*/, '') + .replace(/AllTenants\s*-\s*/, '') + .replace(/AllTenants/, '') + .trim() + } // Load tab options on mount useEffect(() => { - loadTabOptions().then(setTabOptions); - }, []); + loadTabOptions().then(setTabOptions) + }, []) useEffect(() => { // Only update when the route actually changes, not on every render - const currentRoute = router.asPath; + const currentRoute = router.asPath // Skip if this is the same route as last time if (lastRouteRef.current === currentRoute) { - return; + return } - lastRouteRef.current = currentRoute; + lastRouteRef.current = currentRoute // Clear any existing title check interval if (titleCheckIntervalRef.current) { - clearInterval(titleCheckIntervalRef.current); - titleCheckIntervalRef.current = null; + clearInterval(titleCheckIntervalRef.current) + titleCheckIntervalRef.current = null } // Reset check counter - titleCheckCountRef.current = 0; + titleCheckCountRef.current = 0 // Function to check and update title const checkTitle = () => { - titleCheckCountRef.current++; + titleCheckCountRef.current++ // Stop checking after 50 attempts (5 seconds) to prevent infinite intervals if (titleCheckCountRef.current > 50) { if (titleCheckIntervalRef.current) { - clearInterval(titleCheckIntervalRef.current); - titleCheckIntervalRef.current = null; + clearInterval(titleCheckIntervalRef.current) + titleCheckIntervalRef.current = null } - return; + return } - let pageTitle = document.title.replace(" - CIPP", "").trim(); + let pageTitle = document.title.replace(' - CIPP', '').trim() // Remove tenant domain from title (e.g., "Groups - domain.onmicrosoft.com" -> "Groups") // But only if it looks like a domain (contains a dot) - const parts = pageTitle.split(" - "); - if (parts.length > 1 && parts[parts.length - 1].includes(".")) { - pageTitle = parts.slice(0, -1).join(" - ").trim(); + const parts = pageTitle.split(' - ') + if (parts.length > 1 && parts[parts.length - 1].includes('.')) { + pageTitle = parts.slice(0, -1).join(' - ').trim() } // Clean AllTenants from title - pageTitle = cleanPageTitle(pageTitle); + pageTitle = cleanPageTitle(pageTitle) // Skip if title is empty, generic, or error page if ( !pageTitle || - pageTitle === "CIPP" || - pageTitle.toLowerCase().includes("error") || - pageTitle === "404" || - pageTitle === "500" + pageTitle === 'CIPP' || + pageTitle.toLowerCase().includes('error') || + pageTitle === '404' || + pageTitle === '500' ) { - return; + return } // Normalize URL for comparison (remove trailing slashes and query params) const normalizeUrl = (url) => { // Remove query params and trailing slashes for comparison - return url.split("?")[0].replace(/\/$/, "").toLowerCase(); - }; + return url.split('?')[0].replace(/\/$/, '').toLowerCase() + } const currentPage = { title: pageTitle, @@ -157,190 +157,190 @@ export const CippBreadcrumbNav = () => { query: { ...router.query }, fullUrl: router.asPath, timestamp: Date.now(), - }; + } - const normalizedCurrentUrl = normalizeUrl(currentPage.fullUrl); + const normalizedCurrentUrl = normalizeUrl(currentPage.fullUrl) setHistory((prevHistory) => { // Check if last entry has same title AND similar path (prevent duplicate with same content) - const lastEntry = prevHistory[prevHistory.length - 1]; + const lastEntry = prevHistory[prevHistory.length - 1] if (lastEntry) { - const sameTitle = lastEntry.title.trim() === currentPage.title.trim(); - const samePath = normalizeUrl(lastEntry.fullUrl) === normalizedCurrentUrl; + const sameTitle = lastEntry.title.trim() === currentPage.title.trim() + const samePath = normalizeUrl(lastEntry.fullUrl) === normalizedCurrentUrl if (sameTitle && samePath) { // Exact duplicate - don't add, just stop checking if (titleCheckIntervalRef.current) { - clearInterval(titleCheckIntervalRef.current); - titleCheckIntervalRef.current = null; + clearInterval(titleCheckIntervalRef.current) + titleCheckIntervalRef.current = null } - return prevHistory; + return prevHistory } if (samePath && !sameTitle) { // Same URL but title changed - update the entry - const updated = [...prevHistory]; + const updated = [...prevHistory] updated[prevHistory.length - 1] = { ...currentPage, query: getCleanQueryParams(currentPage.query), - }; + } if (titleCheckIntervalRef.current) { - clearInterval(titleCheckIntervalRef.current); - titleCheckIntervalRef.current = null; + clearInterval(titleCheckIntervalRef.current) + titleCheckIntervalRef.current = null } - return updated; + return updated } } // Find if this URL exists anywhere EXCEPT the last position in history const existingIndex = prevHistory.findIndex((entry, index) => { // Skip the last entry since we already checked it above - if (index === prevHistory.length - 1) return false; - return normalizeUrl(entry.fullUrl) === normalizedCurrentUrl; - }); + if (index === prevHistory.length - 1) return false + return normalizeUrl(entry.fullUrl) === normalizedCurrentUrl + }) // URL not in history (except possibly as last entry which we handled) - add as new entry if (existingIndex === -1) { const cleanedCurrentPage = { ...currentPage, query: getCleanQueryParams(currentPage.query), - }; - const newHistory = [...prevHistory, cleanedCurrentPage]; + } + const newHistory = [...prevHistory, cleanedCurrentPage] // Keep only the last MAX_HISTORY_STORAGE pages const trimmedHistory = newHistory.length > MAX_HISTORY_STORAGE ? newHistory.slice(-MAX_HISTORY_STORAGE) - : newHistory; + : newHistory // Don't stop checking yet - title might still be loading - return trimmedHistory; + return trimmedHistory } // URL exists in history but not as last entry - user navigated back // Truncate history after this point and update the entry if (titleCheckIntervalRef.current) { - clearInterval(titleCheckIntervalRef.current); - titleCheckIntervalRef.current = null; + clearInterval(titleCheckIntervalRef.current) + titleCheckIntervalRef.current = null } - const updated = prevHistory.slice(0, existingIndex + 1); + const updated = prevHistory.slice(0, existingIndex + 1) updated[existingIndex] = { ...currentPage, query: getCleanQueryParams(currentPage.query), - }; - return updated; - }); - }; + } + return updated + }) + } // Start checking for title updates - titleCheckIntervalRef.current = setInterval(checkTitle, 100); + titleCheckIntervalRef.current = setInterval(checkTitle, 100) return () => { if (titleCheckIntervalRef.current) { - clearInterval(titleCheckIntervalRef.current); - titleCheckIntervalRef.current = null; + clearInterval(titleCheckIntervalRef.current) + titleCheckIntervalRef.current = null } - }; - }, [router.asPath, router.pathname, router.query]); + } + }, [router.asPath, router.pathname, router.query]) const handleBreadcrumbClick = (index) => { - const page = history[index]; + const page = history[index] if (page) { - const cleanedQuery = getCleanQueryParams(page.query); + const cleanedQuery = getCleanQueryParams(page.query) router.push({ pathname: page.path, query: cleanedQuery, - }); + }) } - }; + } // State to track current page title for hierarchical mode - const [currentPageTitle, setCurrentPageTitle] = useState(null); - const hierarchicalTitleCheckRef = useRef(null); - const hierarchicalCheckCountRef = useRef(0); + const [currentPageTitle, setCurrentPageTitle] = useState(null) + const hierarchicalTitleCheckRef = useRef(null) + const hierarchicalCheckCountRef = useRef(0) // Watch for title changes to update hierarchical breadcrumbs useEffect(() => { - if (mode === "hierarchical") { + if (mode === 'hierarchical') { // Clear any existing interval if (hierarchicalTitleCheckRef.current) { - clearInterval(hierarchicalTitleCheckRef.current); - hierarchicalTitleCheckRef.current = null; + clearInterval(hierarchicalTitleCheckRef.current) + hierarchicalTitleCheckRef.current = null } // Reset counter - hierarchicalCheckCountRef.current = 0; + hierarchicalCheckCountRef.current = 0 const updateTitle = () => { - hierarchicalCheckCountRef.current++; + hierarchicalCheckCountRef.current++ // Stop after 20 attempts (10 seconds) to prevent infinite checking if (hierarchicalCheckCountRef.current > 20) { if (hierarchicalTitleCheckRef.current) { - clearInterval(hierarchicalTitleCheckRef.current); - hierarchicalTitleCheckRef.current = null; + clearInterval(hierarchicalTitleCheckRef.current) + hierarchicalTitleCheckRef.current = null } - return; + return } - let pageTitle = document.title.replace(" - CIPP", "").trim(); - const parts = pageTitle.split(" - "); + let pageTitle = document.title.replace(' - CIPP', '').trim() + const parts = pageTitle.split(' - ') const cleanTitle = - parts.length > 1 && parts[parts.length - 1].includes(".") - ? parts.slice(0, -1).join(" - ").trim() - : pageTitle; + parts.length > 1 && parts[parts.length - 1].includes('.') + ? parts.slice(0, -1).join(' - ').trim() + : pageTitle // Clean AllTenants from title - const finalTitle = cleanPageTitle(cleanTitle); + const finalTitle = cleanPageTitle(cleanTitle) - if (finalTitle && finalTitle !== "CIPP" && !finalTitle.toLowerCase().includes("loading")) { - setCurrentPageTitle(finalTitle); + if (finalTitle && finalTitle !== 'CIPP' && !finalTitle.toLowerCase().includes('loading')) { + setCurrentPageTitle(finalTitle) // Stop checking once we have a valid title if (hierarchicalTitleCheckRef.current) { - clearInterval(hierarchicalTitleCheckRef.current); - hierarchicalTitleCheckRef.current = null; + clearInterval(hierarchicalTitleCheckRef.current) + hierarchicalTitleCheckRef.current = null } } - }; + } // Initial update - updateTitle(); + updateTitle() // Only start interval if we don't have a valid title yet - if (!currentPageTitle || currentPageTitle.toLowerCase().includes("loading")) { - hierarchicalTitleCheckRef.current = setInterval(updateTitle, 500); + if (!currentPageTitle || currentPageTitle.toLowerCase().includes('loading')) { + hierarchicalTitleCheckRef.current = setInterval(updateTitle, 500) } return () => { if (hierarchicalTitleCheckRef.current) { - clearInterval(hierarchicalTitleCheckRef.current); - hierarchicalTitleCheckRef.current = null; + clearInterval(hierarchicalTitleCheckRef.current) + hierarchicalTitleCheckRef.current = null } - }; + } } - }, [mode, router.pathname]); + }, [mode, router.pathname]) // Build hierarchical breadcrumbs from config.js navigation structure const buildHierarchicalBreadcrumbs = () => { - const currentPath = router.pathname; + const currentPath = router.pathname // Helper to check if paths match (handles dynamic routes) const pathsMatch = (menuPath, currentPath) => { - if (!menuPath) return false; + if (!menuPath) return false // Exact match - if (menuPath === currentPath) return true; + if (menuPath === currentPath) return true // Check if current path starts with menu path (for nested routes) // e.g., menu: "/identity/administration/users" matches "/identity/administration/users/edit" - if (currentPath.startsWith(menuPath + "/")) return true; + if (currentPath.startsWith(menuPath + '/')) return true - return false; - }; + return false + } const findPathInMenu = (items, path = []) => { for (const item of items) { - const currentBreadcrumb = [...path]; + const currentBreadcrumb = [...path] // Add current item to path if it has a title // Include all items (headers, groups, and pages) to show full hierarchy @@ -350,44 +350,44 @@ export const CippBreadcrumbNav = () => { path: item.path, type: item.type, query: {}, // Menu items don't have query params by default - }); + }) } // Check if this item matches the current path if (item.path && pathsMatch(item.path, currentPath)) { // If this is the current page, include current query params (cleaned) if (item.path === currentPath) { - const lastItem = currentBreadcrumb[currentBreadcrumb.length - 1]; + const lastItem = currentBreadcrumb[currentBreadcrumb.length - 1] if (lastItem) { - lastItem.query = getCleanQueryParams(router.query); + lastItem.query = getCleanQueryParams(router.query) } } - return currentBreadcrumb; + return currentBreadcrumb } // Recursively search children if (item.items && item.items.length > 0) { - const result = findPathInMenu(item.items, currentBreadcrumb); + const result = findPathInMenu(item.items, currentBreadcrumb) if (result.length > 0) { - return result; + return result } } } - return []; - }; + return [] + } - let result = findPathInMenu(nativeMenuItems); + let result = findPathInMenu(nativeMenuItems) // If we found a menu item, check if the current path matches any tab // If so, tabOptions wins and we use its label if (result.length > 0 && tabOptions.length > 0) { - const normalizedCurrentPath = currentPath.replace(/\/$/, ""); + const normalizedCurrentPath = currentPath.replace(/\/$/, '') // Check if current path matches any tab (exact match) const matchingTab = tabOptions.find((tab) => { - const normalizedTabPath = tab.path.replace(/\/$/, ""); - return normalizedTabPath === normalizedCurrentPath; - }); + const normalizedTabPath = tab.path.replace(/\/$/, '') + return normalizedTabPath === normalizedCurrentPath + }) if (matchingTab) { // Tab matches the current path - use tab's label instead of config's @@ -396,32 +396,32 @@ export const CippBreadcrumbNav = () => { return { ...item, title: matchingTab.title, - type: "tab", - }; + type: 'tab', + } } - return item; - }); + return item + }) } } // If not found in main menu, check if it's a tab page if (result.length === 0 && tabOptions.length > 0) { - const normalizedCurrentPath = currentPath.replace(/\/$/, ""); + const normalizedCurrentPath = currentPath.replace(/\/$/, '') // Find matching tab option const matchingTab = tabOptions.find((tab) => { - const normalizedTabPath = tab.path.replace(/\/$/, ""); - return normalizedTabPath === normalizedCurrentPath; - }); + const normalizedTabPath = tab.path.replace(/\/$/, '') + return normalizedTabPath === normalizedCurrentPath + }) if (matchingTab) { // Find the base page in the menu and build full path to it - const normalizedBasePath = matchingTab.basePath?.replace(/\/$/, ""); + const normalizedBasePath = matchingTab.basePath?.replace(/\/$/, '') // Recursively find the base page and build breadcrumb path const findBasePageWithPath = (items, path = []) => { for (const item of items) { - const currentBreadcrumb = [...path]; + const currentBreadcrumb = [...path] // Add current item to path if it has a title if (item.title) { @@ -430,185 +430,188 @@ export const CippBreadcrumbNav = () => { path: item.path, type: item.type, query: {}, // Menu items don't have query params by default - }); + }) } // Check if this item matches the base path if (item.path) { - const normalizedItemPath = item.path.replace(/\/$/, ""); + const normalizedItemPath = item.path.replace(/\/$/, '') if ( normalizedItemPath === normalizedBasePath || normalizedItemPath.startsWith(normalizedBasePath) ) { - return currentBreadcrumb; + return currentBreadcrumb } } // Recursively search children if (item.items && item.items.length > 0) { - const found = findBasePageWithPath(item.items, currentBreadcrumb); + const found = findBasePageWithPath(item.items, currentBreadcrumb) if (found.length > 0) { - return found; + return found } } } - return []; - }; + return [] + } - const basePagePath = findBasePageWithPath(nativeMenuItems); + const basePagePath = findBasePageWithPath(nativeMenuItems) if (basePagePath.length > 0) { - result = basePagePath; + result = basePagePath // Add the tab as the final breadcrumb with current query params (cleaned) result.push({ title: matchingTab.title, path: matchingTab.path, - type: "tab", + type: 'tab', query: getCleanQueryParams(router.query), // Include current query params for tab page - }); + }) } } } // Check if we're on a nested page under a menu item (e.g., edit page) if (result.length > 0) { - const lastItem = result[result.length - 1]; + const lastItem = result[result.length - 1] if (lastItem.path && lastItem.path !== currentPath && currentPath.startsWith(lastItem.path)) { // Use the tracked page title if available, otherwise fall back to document.title - let tabTitle = currentPageTitle || document.title.replace(" - CIPP", "").trim(); + let tabTitle = currentPageTitle || document.title.replace(' - CIPP', '').trim() // Clean AllTenants from title - tabTitle = cleanPageTitle(tabTitle); + tabTitle = cleanPageTitle(tabTitle) // Add tab as an additional breadcrumb item if ( tabTitle && tabTitle !== lastItem.title && - !tabTitle.toLowerCase().includes("loading") + !tabTitle.toLowerCase().includes('loading') ) { result.push({ title: tabTitle, path: currentPath, - type: "tab", + type: 'tab', query: getCleanQueryParams(router.query), // Include current query params (cleaned) - }); + }) } } } - return result; - }; + return result + } // Check if a path is valid and return its title from navigation or tabs const getPathInfo = (path) => { - if (!path) return { isValid: false, title: null }; + if (!path) return { isValid: false, title: null } - const normalizedPath = path.replace(/\/$/, ""); + const normalizedPath = path.replace(/\/$/, '') // Helper function to recursively search menu items const findInMenu = (items) => { for (const item of items) { if (item.path) { - const normalizedItemPath = item.path.replace(/\/$/, ""); + const normalizedItemPath = item.path.replace(/\/$/, '') if (normalizedItemPath === normalizedPath) { - return { isValid: true, title: item.title }; + return { isValid: true, title: item.title } } } if (item.items && item.items.length > 0) { - const found = findInMenu(item.items); + const found = findInMenu(item.items) if (found.isValid) { - return found; + return found } } } - return { isValid: false, title: null }; - }; + return { isValid: false, title: null } + } // Check if path exists in navigation - const menuResult = findInMenu(nativeMenuItems); + const menuResult = findInMenu(nativeMenuItems) if (menuResult.isValid) { - return menuResult; + return menuResult } // Check if path exists in tab options - const matchingTab = tabOptions.find((tab) => tab.path.replace(/\/$/, "") === normalizedPath); + const matchingTab = tabOptions.find((tab) => tab.path.replace(/\/$/, '') === normalizedPath) if (matchingTab) { - return { isValid: true, title: matchingTab.title }; + return { isValid: true, title: matchingTab.title } } - return { isValid: false, title: null }; - }; + return { isValid: false, title: null } + } // Handle click for hierarchical breadcrumbs const handleHierarchicalClick = (path, query) => { if (path) { - const cleanedQuery = getCleanQueryParams(query); + const cleanedQuery = getCleanQueryParams(query) if (cleanedQuery && Object.keys(cleanedQuery).length > 0) { router.push({ pathname: path, query: cleanedQuery, - }); + }) } else { - router.push(path); + router.push(path) } } - }; + } // Toggle between modes const toggleMode = () => { setMode((prevMode) => { - const newMode = prevMode === "hierarchical" ? "history" : "hierarchical"; - settings.handleUpdate({ breadcrumbMode: newMode }); - return newMode; - }); - }; + const newMode = prevMode === 'hierarchical' ? 'history' : 'hierarchical' + settings.handleUpdate({ breadcrumbMode: newMode }) + return newMode + }) + } // Render based on mode - if (mode === "hierarchical") { - let breadcrumbs = buildHierarchicalBreadcrumbs(); + if (mode === 'hierarchical') { + let breadcrumbs = buildHierarchicalBreadcrumbs() // Fallback: If no breadcrumbs found in navigation config, generate from URL path if (breadcrumbs.length === 0) { - const pathSegments = router.pathname.split("/").filter((segment) => segment); + const pathSegments = router.pathname.split('/').filter((segment) => segment) if (pathSegments.length > 0) { breadcrumbs = pathSegments.map((segment, index) => { // Build the path up to this segment - const path = "/" + pathSegments.slice(0, index + 1).join("/"); + const path = '/' + pathSegments.slice(0, index + 1).join('/') // Format segment as title (replace hyphens with spaces, capitalize words) const title = segment - .split("-") + .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); + .join(' ') return { title, path, - type: "fallback", + type: 'fallback', query: index === pathSegments.length - 1 ? getCleanQueryParams(router.query) : {}, - }; - }); + } + }) // If we have a current page title from document.title, use it for the last breadcrumb if ( currentPageTitle && - currentPageTitle !== "CIPP" && - !currentPageTitle.toLowerCase().includes("loading") + currentPageTitle !== 'CIPP' && + !currentPageTitle.toLowerCase().includes('loading') ) { - breadcrumbs[breadcrumbs.length - 1].title = cleanPageTitle(currentPageTitle); + breadcrumbs[breadcrumbs.length - 1].title = cleanPageTitle(currentPageTitle) } } } // Don't show if still no breadcrumbs found if (breadcrumbs.length === 0) { - return null; + return null } return ( - + @@ -617,13 +620,13 @@ export const CippBreadcrumbNav = () => { } aria-label="page hierarchy" - sx={{ fontSize: "0.875rem", flexGrow: 1 }} + sx={{ fontSize: '0.875rem', flexGrow: 1 }} > {breadcrumbs.map((crumb, index) => { - const isLast = index === breadcrumbs.length - 1; - const pathInfo = getPathInfo(crumb.path); + const isLast = index === breadcrumbs.length - 1 + const pathInfo = getPathInfo(crumb.path) // Use title from nav/tabs if available, otherwise use the crumb's title - const displayTitle = pathInfo.title || crumb.title; + const displayTitle = pathInfo.title || crumb.title // Items without paths (headers/groups) - show as text if (!crumb.path) { @@ -636,7 +639,7 @@ export const CippBreadcrumbNav = () => { > {displayTitle} - ); + ) } // Items with valid paths are clickable @@ -649,48 +652,51 @@ export const CippBreadcrumbNav = () => { variant="subtitle2" onClick={() => handleHierarchicalClick(crumb.path, crumb.query)} sx={{ - textDecoration: "none", - color: isLast ? "text.primary" : "text.secondary", + textDecoration: 'none', + color: isLast ? 'text.primary' : 'text.secondary', fontWeight: isLast ? 500 : 400, - "&:hover": { - textDecoration: "underline", - color: "primary.main", + '&:hover': { + textDecoration: 'underline', + color: 'primary.main', }, }} > {displayTitle} - ); + ) } else { // Invalid path - show as text only return ( {displayTitle} - ); + ) } })} - ); + ) } // Default mode: history-based breadcrumbs // Don't show breadcrumbs if we have no history if (history.length === 0) { - return null; + return null } // Show only the last MAX_BREADCRUMB_DISPLAY items - const visibleHistory = history.slice(-MAX_BREADCRUMB_DISPLAY); + const visibleHistory = history.slice(-MAX_BREADCRUMB_DISPLAY) return ( - + @@ -700,12 +706,12 @@ export const CippBreadcrumbNav = () => { maxItems={MAX_BREADCRUMB_DISPLAY} separator={} aria-label="navigation history" - sx={{ fontSize: "0.875rem", flexGrow: 1 }} + sx={{ fontSize: '0.875rem', flexGrow: 1 }} > {visibleHistory.map((page, index) => { - const isLast = index === visibleHistory.length - 1; + const isLast = index === visibleHistory.length - 1 // Calculate the actual index in the full history - const actualIndex = history.length - visibleHistory.length + index; + const actualIndex = history.length - visibleHistory.length + index if (isLast) { return ( @@ -717,7 +723,7 @@ export const CippBreadcrumbNav = () => { > {page.title} - ); + ) } return ( @@ -727,19 +733,19 @@ export const CippBreadcrumbNav = () => { variant="subtitle2" onClick={() => handleBreadcrumbClick(actualIndex)} sx={{ - textDecoration: "none", - color: "text.secondary", - "&:hover": { - textDecoration: "underline", - color: "primary.main", + textDecoration: 'none', + color: 'text.secondary', + '&:hover': { + textDecoration: 'underline', + color: 'primary.main', }, }} > {page.title} - ); + ) })} - ); -}; + ) +} diff --git a/src/components/CippComponents/CippSpeedDial.jsx b/src/components/CippComponents/CippSpeedDial.jsx index 15a1b4547d9b..0a2365bc4707 100644 --- a/src/components/CippComponents/CippSpeedDial.jsx +++ b/src/components/CippComponents/CippSpeedDial.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect } from 'react' import { SpeedDial, SpeedDialAction, @@ -11,10 +11,10 @@ import { Snackbar, Alert, CircularProgress, -} from "@mui/material"; -import { Close as CloseIcon } from "@mui/icons-material"; -import { useForm } from "react-hook-form"; -import { CippFormComponent } from "../../components/CippComponents/CippFormComponent"; +} from '@mui/material' +import { Close as CloseIcon } from '@mui/icons-material' +import { useForm } from 'react-hook-form' +import { CippFormComponent } from '../../components/CippComponents/CippFormComponent' const CippSpeedDial = ({ actions = [], @@ -22,92 +22,92 @@ const CippSpeedDial = ({ icon, openIcon = , }) => { - const [openDialogs, setOpenDialogs] = useState({}); - const [loading, setLoading] = useState(false); - const [showSnackbar, setShowSnackbar] = useState(false); - const [speedDialOpen, setSpeedDialOpen] = useState(false); - const [isHovering, setIsHovering] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(""); + const [openDialogs, setOpenDialogs] = useState({}) + const [loading, setLoading] = useState(false) + const [showSnackbar, setShowSnackbar] = useState(false) + const [speedDialOpen, setSpeedDialOpen] = useState(false) + const [isHovering, setIsHovering] = useState(false) + const [snackbarMessage, setSnackbarMessage] = useState('') const formControls = actions.reduce((acc, action) => { if (action.form) { acc[action.id] = useForm({ - mode: "onChange", + mode: 'onChange', defaultValues: action.form.defaultValues || {}, - }); + }) } - return acc; - }, {}); + return acc + }, {}) const handleSpeedDialClose = (event, reason) => { - if (reason === "toggle") { - setSpeedDialOpen(false); - setIsHovering(false); - return; + if (reason === 'toggle') { + setSpeedDialOpen(false) + setIsHovering(false) + return } if (!isHovering) { setTimeout(() => { - setSpeedDialOpen(false); - }, 200); + setSpeedDialOpen(false) + }, 200) } - }; + } const handleMouseEnter = () => { - setIsHovering(true); - setSpeedDialOpen(true); - }; + setIsHovering(true) + setSpeedDialOpen(true) + } const handleMouseLeave = () => { - setIsHovering(false); - handleSpeedDialClose(); - }; + setIsHovering(false) + handleSpeedDialClose() + } const handleDialogOpen = (actionId) => { - setOpenDialogs((prev) => ({ ...prev, [actionId]: true })); - }; + setOpenDialogs((prev) => ({ ...prev, [actionId]: true })) + } const handleDialogClose = (actionId) => { - setOpenDialogs((prev) => ({ ...prev, [actionId]: false })); - }; + setOpenDialogs((prev) => ({ ...prev, [actionId]: false })) + } const handleSubmit = async (actionId, data) => { - if (!actions.find((a) => a.id === actionId)?.onSubmit) return; + if (!actions.find((a) => a.id === actionId)?.onSubmit) return - setLoading(true); + setLoading(true) try { - const action = actions.find((a) => a.id === actionId); - const result = await action.onSubmit(data); + const action = actions.find((a) => a.id === actionId) + const result = await action.onSubmit(data) if (result.success) { - formControls[actionId]?.reset(); - handleDialogClose(actionId); + formControls[actionId]?.reset() + handleDialogClose(actionId) } - setSnackbarMessage(result.message); - setShowSnackbar(true); + setSnackbarMessage(result.message) + setShowSnackbar(true) } catch (error) { - console.error(`Error submitting ${actionId}:`, error); - setSnackbarMessage("An error occurred while submitting"); - setShowSnackbar(true); + console.error(`Error submitting ${actionId}:`, error) + setSnackbarMessage('An error occurred while submitting') + setShowSnackbar(true) } finally { - setLoading(false); + setLoading(false) } - }; + } useEffect(() => { const handleClickOutside = (event) => { if (speedDialOpen) { - const speedDial = document.querySelector('[aria-label="Navigation SpeedDial"]'); + const speedDial = document.querySelector('[aria-label="Navigation SpeedDial"]') if (speedDial && !speedDial.contains(event.target)) { - setSpeedDialOpen(false); + setSpeedDialOpen(false) } } - }; + } - document.addEventListener("click", handleClickOutside); + document.addEventListener('click', handleClickOutside) return () => { - document.removeEventListener("click", handleClickOutside); - }; - }, [speedDialOpen]); + document.removeEventListener('click', handleClickOutside) + } + }, [speedDialOpen]) return ( <> @@ -115,13 +115,13 @@ const CippSpeedDial = ({ ariaLabel="Navigation SpeedDial" data-tutorial="speed-dial" sx={{ - position: "fixed", + position: 'fixed', ...position, - "& .MuiFab-primary": { + '& .MuiFab-primary': { width: 46, height: 46, - "&:hover": { - backgroundColor: "primary.dark", + '&:hover': { + backgroundColor: 'primary.dark', }, }, }} @@ -139,27 +139,27 @@ const CippSpeedDial = ({ tooltipTitle={action.name} onClick={() => { if (action.form) { - handleDialogOpen(action.id); + handleDialogOpen(action.id) } else if (action.onClick) { - action.onClick(); + action.onClick() } - setSpeedDialOpen(false); + setSpeedDialOpen(false) }} tooltipOpen sx={{ - "&.MuiSpeedDialAction-fab": { - backgroundColor: "background.paper", - "&:hover": { - backgroundColor: "action.hover", + '&.MuiSpeedDialAction-fab': { + backgroundColor: 'background.paper', + '&:hover': { + backgroundColor: 'action.hover', }, }, - "& .MuiSpeedDialAction-staticTooltipLabel": { - cursor: "pointer", - whiteSpace: "nowrap", - marginRight: "10px", - padding: "6px 10px", - "&:hover": { - backgroundColor: "action.hover", + '& .MuiSpeedDialAction-staticTooltipLabel': { + cursor: 'pointer', + whiteSpace: 'nowrap', + marginRight: '10px', + padding: '6px 10px', + '&:hover': { + backgroundColor: 'action.hover', }, }, }} @@ -184,10 +184,10 @@ const CippSpeedDial = ({ name={action.form.fieldName} required formControl={formControls[action.id]} - style={{ minHeight: "150px" }} + style={{ minHeight: '150px' }} editorProps={{ attributes: { - style: "min-height: 150px; font-size: 1.1rem; padding: 1rem;", + style: 'min-height: 150px; font-size: 1.1rem; padding: 1rem;', }, }} /> @@ -205,7 +205,7 @@ const CippSpeedDial = ({ disabled={loading} startIcon={loading ? : null} > - {loading ? "Submitting..." : action.form.submitText || "Submit"} + {loading ? 'Submitting...' : action.form.submitText || 'Submit'} @@ -215,14 +215,14 @@ const CippSpeedDial = ({ open={showSnackbar} autoHideDuration={6000} onClose={() => setShowSnackbar(false)} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > - setShowSnackbar(false)} severity="success" sx={{ width: "100%" }}> + setShowSnackbar(false)} severity="success" sx={{ width: '100%' }}> {snackbarMessage} - ); -}; + ) +} -export default CippSpeedDial; +export default CippSpeedDial diff --git a/src/components/CippComponents/CippTutorialDialog.jsx b/src/components/CippComponents/CippTutorialDialog.jsx index f52c9234d6ca..016c8ba01e04 100644 --- a/src/components/CippComponents/CippTutorialDialog.jsx +++ b/src/components/CippComponents/CippTutorialDialog.jsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo } from 'react' import { Dialog, DialogTitle, @@ -17,54 +17,52 @@ import { Divider, IconButton, Tooltip, -} from "@mui/material"; +} from '@mui/material' import { PlayArrow as PlayIcon, CheckCircle as CompletedIcon, School as TutorialIcon, Search as SearchIcon, Replay as ResetIcon, -} from "@mui/icons-material"; -import { useTutorials } from "../../contexts/tutorial-context"; -import { useRouter } from "next/router"; +} from '@mui/icons-material' +import { useTutorials } from '../../contexts/tutorial-context' +import { useRouter } from 'next/router' const CippTutorialDialog = ({ open, onClose }) => { - const { tutorials, completedIds, startTutorial, resetProgress } = useTutorials(); - const [search, setSearch] = useState(""); - const router = useRouter(); + const { tutorials, completedIds, startTutorial, resetProgress } = useTutorials() + const [search, setSearch] = useState('') + const router = useRouter() const grouped = useMemo(() => { const filtered = tutorials.filter((t) => { - const q = search.toLowerCase(); + const q = search.toLowerCase() return ( t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q) || t.category?.toLowerCase().includes(q) - ); - }); + ) + }) return filtered.reduce((acc, tutorial) => { - const cat = tutorial.category || "General"; - if (!acc[cat]) acc[cat] = []; - acc[cat].push(tutorial); - return acc; - }, {}); - }, [tutorials, search]); + const cat = tutorial.category || 'General' + if (!acc[cat]) acc[cat] = [] + acc[cat].push(tutorial) + return acc + }, {}) + }, [tutorials, search]) const handleStart = (tutorial) => { - onClose(); + onClose() // Small delay to let dialog close animation finish - setTimeout(() => startTutorial(tutorial), 300); - }; + setTimeout(() => startTutorial(tutorial), 300) + } - const categoryKeys = Object.keys(grouped).sort(); + const categoryKeys = Object.keys(grouped).sort() return ( - - + + Tutorials @@ -94,7 +92,7 @@ const CippTutorialDialog = ({ open, onClose }) => { /> {categoryKeys.length === 0 && ( - + No tutorials found. )} @@ -106,9 +104,8 @@ const CippTutorialDialog = ({ open, onClose }) => { {grouped[category].map((tutorial) => { - const isCompleted = completedIds.includes(tutorial.id); - const isOnPage = - !tutorial.pages?.length || tutorial.pages.includes(router.pathname); + const isCompleted = completedIds.includes(tutorial.id) + const isOnPage = !tutorial.pages?.length || tutorial.pages.includes(router.pathname) return ( { primary={tutorial.title} secondary={tutorial.description} slotProps={{ - primary: { variant: "body2", fontWeight: 500 }, - secondary: { variant: "caption" }, + primary: { variant: 'body2', fontWeight: 500 }, + secondary: { variant: 'caption' }, }} /> - - {isCompleted && } - {!isOnPage && ( - + + {isCompleted && ( + )} + {!isOnPage && } { /> - ); + ) })} @@ -151,13 +148,13 @@ const CippTutorialDialog = ({ open, onClose }) => { ))} - + {completedIds.length} of {tutorials.length} completed - ); -}; + ) +} -export default CippTutorialDialog; +export default CippTutorialDialog diff --git a/src/contexts/tutorial-context.js b/src/contexts/tutorial-context.js index 954b641d8b0b..749f645aa15f 100644 --- a/src/contexts/tutorial-context.js +++ b/src/contexts/tutorial-context.js @@ -1,37 +1,37 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { driver } from "driver.js"; -import { useRouter } from "next/router"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { driver } from 'driver.js' +import { useRouter } from 'next/router' -const STORAGE_KEY = "cipp.tutorials.completed"; +const STORAGE_KEY = 'cipp.tutorials.completed' const getCompletedTutorials = () => { try { - const stored = localStorage.getItem(STORAGE_KEY); - return stored ? JSON.parse(stored) : []; + const stored = localStorage.getItem(STORAGE_KEY) + return stored ? JSON.parse(stored) : [] } catch { - return []; + return [] } -}; +} const storeCompletedTutorial = (id) => { try { - const completed = getCompletedTutorials(); + const completed = getCompletedTutorials() if (!completed.includes(id)) { - completed.push(id); - localStorage.setItem(STORAGE_KEY, JSON.stringify(completed)); + completed.push(id) + localStorage.setItem(STORAGE_KEY, JSON.stringify(completed)) } } catch { // ignore } -}; +} const resetCompletedTutorials = () => { try { - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY) } catch { // ignore } -}; +} const TutorialContext = createContext({ tutorials: [], @@ -40,116 +40,114 @@ const TutorialContext = createContext({ startTutorial: () => {}, resetProgress: () => {}, getTutorialsForPage: () => [], -}); +}) // Load all tutorial JSON files from the data/tutorials folder at build time const loadTutorials = () => { - const context = require.context("../data/tutorials", false, /\.json$/); + const context = require.context('../data/tutorials', false, /\.json$/) return context.keys().map((key) => { - const tutorial = context(key); - return tutorial.default || tutorial; - }); -}; + const tutorial = context(key) + return tutorial.default || tutorial + }) +} export const TutorialProvider = ({ children }) => { - const [tutorials] = useState(() => loadTutorials()); - const [completedIds, setCompletedIds] = useState([]); - const [activeTutorial, setActiveTutorial] = useState(null); - const driverRef = useRef(null); - const router = useRouter(); + const [tutorials] = useState(() => loadTutorials()) + const [completedIds, setCompletedIds] = useState([]) + const [activeTutorial, setActiveTutorial] = useState(null) + const driverRef = useRef(null) + const router = useRouter() useEffect(() => { - setCompletedIds(getCompletedTutorials()); - }, []); + setCompletedIds(getCompletedTutorials()) + }, []) // Launch tutorial from ?tutorial=$id query param useEffect(() => { - if (!router.isReady || activeTutorial) return; - const tutorialId = router.query.tutorial; - if (!tutorialId) return; + if (!router.isReady || activeTutorial) return + const tutorialId = router.query.tutorial + if (!tutorialId) return - const match = tutorials.find((t) => t.id === tutorialId); - if (!match) return; + const match = tutorials.find((t) => t.id === tutorialId) + if (!match) return // Strip the query param so it doesn't re-trigger - const { tutorial: _, ...rest } = router.query; - router.replace({ pathname: router.pathname, query: rest }, undefined, { shallow: true }); + const { tutorial: _, ...rest } = router.query + router.replace({ pathname: router.pathname, query: rest }, undefined, { shallow: true }) // Delay to let the page fully render - setTimeout(() => runDriver(match), 600); - }, [router.isReady, router.query.tutorial, tutorials]); + setTimeout(() => runDriver(match), 600) + }, [router.isReady, router.query.tutorial, tutorials]) // Cleanup driver on unmount or route change useEffect(() => { return () => { if (driverRef.current) { - driverRef.current.destroy(); - driverRef.current = null; + driverRef.current.destroy() + driverRef.current = null } - }; - }, []); + } + }, []) const startTutorial = useCallback( (tutorial) => { if (driverRef.current) { - driverRef.current.destroy(); + driverRef.current.destroy() } // If tutorial specifies pages and we're not on any of them, navigate first if (tutorial.pages?.length && !tutorial.pages.includes(router.pathname)) { router.push(tutorial.pages[0]).then(() => { // Small delay to let the page render before starting the tour - setTimeout(() => runDriver(tutorial), 500); - }); - return; + setTimeout(() => runDriver(tutorial), 500) + }) + return } - runDriver(tutorial); + runDriver(tutorial) }, [router] - ); + ) const runDriver = useCallback((tutorial) => { - setActiveTutorial(tutorial); + setActiveTutorial(tutorial) const driverObj = driver({ showProgress: true, animate: true, allowClose: true, - overlayColor: "rgba(0, 0, 0, 0.6)", + overlayColor: 'rgba(0, 0, 0, 0.6)', stagePadding: 8, stageRadius: 8, - popoverClass: "cipp-tutorial-popover", - nextBtnText: "Next →", - prevBtnText: "← Back", - doneBtnText: "Done ✓", - progressText: "{{current}} of {{total}}", + popoverClass: 'cipp-tutorial-popover', + nextBtnText: 'Next →', + prevBtnText: '← Back', + doneBtnText: 'Done ✓', + progressText: '{{current}} of {{total}}', steps: tutorial.steps, onDestroyed: () => { - storeCompletedTutorial(tutorial.id); - setCompletedIds(getCompletedTutorials()); - setActiveTutorial(null); - driverRef.current = null; + storeCompletedTutorial(tutorial.id) + setCompletedIds(getCompletedTutorials()) + setActiveTutorial(null) + driverRef.current = null }, - }); + }) - driverRef.current = driverObj; - driverObj.drive(); - }, []); + driverRef.current = driverObj + driverObj.drive() + }, []) const resetProgress = useCallback(() => { - resetCompletedTutorials(); - setCompletedIds([]); - }, []); + resetCompletedTutorials() + setCompletedIds([]) + }, []) const getTutorialsForPage = useCallback( (pathname) => { - return tutorials.filter( - (t) => !t.pages || t.pages.length === 0 || t.pages.includes(pathname) - ); + return tutorials.filter((t) => !t.pages || t.pages.length === 0 || t.pages.includes(pathname)) }, [tutorials] - ); + ) const value = useMemo( () => ({ @@ -161,9 +159,9 @@ export const TutorialProvider = ({ children }) => { getTutorialsForPage, }), [tutorials, activeTutorial, completedIds, startTutorial, resetProgress, getTutorialsForPage] - ); + ) - return {children}; -}; + return {children} +} -export const useTutorials = () => useContext(TutorialContext); +export const useTutorials = () => useContext(TutorialContext) diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 2c7e0a2507ad..baa5d1ad9f9f 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -279,7 +279,11 @@ export const TopNav = (props) => { {!mdDown && ( - + )} {mdDown && ( diff --git a/src/pages/dashboardv2/index.js b/src/pages/dashboardv2/index.js index dfd9d9a76528..48e3bd3b3b62 100644 --- a/src/pages/dashboardv2/index.js +++ b/src/pages/dashboardv2/index.js @@ -194,7 +194,10 @@ const Page = () => { - + Date: Mon, 25 May 2026 20:37:52 +0200 Subject: [PATCH 095/164] demo data --- src/contexts/tutorial-context.js | 10 +++ src/data/dashboardv2-demo-data.js | 138 +++++++++++++++--------------- 2 files changed, 79 insertions(+), 69 deletions(-) diff --git a/src/contexts/tutorial-context.js b/src/contexts/tutorial-context.js index 749f645aa15f..b32ab50c8fa8 100644 --- a/src/contexts/tutorial-context.js +++ b/src/contexts/tutorial-context.js @@ -73,6 +73,16 @@ export const TutorialProvider = ({ children }) => { // Strip the query param so it doesn't re-trigger const { tutorial: _, ...rest } = router.query + + // If the tutorial has a target page and we're not on it, navigate there first + const targetPage = match.pages?.[0] + if (targetPage && router.pathname !== targetPage) { + router.replace({ pathname: targetPage, query: rest }, undefined).then(() => { + setTimeout(() => runDriver(match), 600) + }) + return + } + router.replace({ pathname: router.pathname, query: rest }, undefined, { shallow: true }) // Delay to let the page fully render diff --git a/src/data/dashboardv2-demo-data.js b/src/data/dashboardv2-demo-data.js index e5e23eee579a..4f0db4c12aad 100644 --- a/src/data/dashboardv2-demo-data.js +++ b/src/data/dashboardv2-demo-data.js @@ -1,8 +1,8 @@ // Demo data structure matching Zero Trust Assessment export const dashboardDemoData = { - ExecutedAt: "2025-12-16T10:00:00Z", - TenantName: "Demo Tenant", - Domain: "demo.contoso.com", + ExecutedAt: '2025-12-16T10:00:00Z', + TenantName: 'Demo Tenant', + Domain: 'demo.contoso.com', TestResultSummary: { IdentityPassed: 85, IdentityTotal: 100, @@ -13,100 +13,100 @@ export const dashboardDemoData = { }, TenantInfo: { TenantOverview: { - UserCount: 1250, - GuestCount: 85, - GroupCount: 340, - ApplicationCount: 156, - DeviceCount: 765, - ManagedDeviceCount: 733, + UserCount: 0, + GuestCount: 0, + GroupCount: 0, + ApplicationCount: 0, + DeviceCount: 0, + ManagedDeviceCount: 0, }, OverviewCaMfaAllUsers: { description: - "Over the past 30 days, 68.5% of sign-ins were protected by conditional access policies enforcing multifactor authentication.", + 'Over the past 30 days, 68.5% of sign-ins were protected by conditional access policies enforcing multifactor authentication.', nodes: [ - { source: "User sign in", target: "No CA applied", value: 394 }, - { source: "User sign in", target: "CA applied", value: 856 }, - { source: "CA applied", target: "No MFA", value: 146 }, - { source: "CA applied", target: "MFA", value: 710 }, + { source: 'User sign in', target: 'No CA applied', value: 394 }, + { source: 'User sign in', target: 'CA applied', value: 856 }, + { source: 'CA applied', target: 'No MFA', value: 146 }, + { source: 'CA applied', target: 'MFA', value: 710 }, ], }, OverviewCaDevicesAllUsers: { - description: "Over the past 30 days, 71.2% of sign-ins were from compliant devices.", + description: 'Over the past 30 days, 71.2% of sign-ins were from compliant devices.', nodes: [ - { source: "User sign in", target: "Unmanaged", value: 500 }, - { source: "User sign in", target: "Managed", value: 1150 }, - { source: "Managed", target: "Non-compliant", value: 260 }, - { source: "Managed", target: "Compliant", value: 890 }, + { source: 'User sign in', target: 'Unmanaged', value: 500 }, + { source: 'User sign in', target: 'Managed', value: 1150 }, + { source: 'Managed', target: 'Non-compliant', value: 260 }, + { source: 'Managed', target: 'Compliant', value: 890 }, ], }, OverviewAuthMethodsPrivilegedUsers: { - description: "Authentication methods used by privileged users over the past 30 days.", + description: 'Authentication methods used by privileged users over the past 30 days.', nodes: [ - { source: "Users", target: "Single factor", value: 5 }, - { source: "Users", target: "Phishable", value: 28 }, - { source: "Users", target: "Phish resistant", value: 15 }, - { source: "Phishable", target: "Phone", value: 8 }, - { source: "Phishable", target: "Authenticator", value: 20 }, - { source: "Phish resistant", target: "Passkey", value: 12 }, - { source: "Phish resistant", target: "WHfB", value: 3 }, + { source: 'Users', target: 'Single factor', value: 5 }, + { source: 'Users', target: 'Phishable', value: 28 }, + { source: 'Users', target: 'Phish resistant', value: 15 }, + { source: 'Phishable', target: 'Phone', value: 8 }, + { source: 'Phishable', target: 'Authenticator', value: 20 }, + { source: 'Phish resistant', target: 'Passkey', value: 12 }, + { source: 'Phish resistant', target: 'WHfB', value: 3 }, ], }, OverviewAuthMethodsAllUsers: { - description: "Authentication methods used by all users over the past 30 days.", + description: 'Authentication methods used by all users over the past 30 days.', nodes: [ - { source: "Users", target: "Single factor", value: 120 }, - { source: "Users", target: "Phishable", value: 580 }, - { source: "Users", target: "Phish resistant", value: 550 }, - { source: "Phishable", target: "Phone", value: 180 }, - { source: "Phishable", target: "Authenticator", value: 400 }, - { source: "Phish resistant", target: "Passkey", value: 450 }, - { source: "Phish resistant", target: "WHfB", value: 100 }, + { source: 'Users', target: 'Single factor', value: 120 }, + { source: 'Users', target: 'Phishable', value: 580 }, + { source: 'Users', target: 'Phish resistant', value: 550 }, + { source: 'Phishable', target: 'Phone', value: 180 }, + { source: 'Phishable', target: 'Authenticator', value: 400 }, + { source: 'Phish resistant', target: 'Passkey', value: 450 }, + { source: 'Phish resistant', target: 'WHfB', value: 100 }, ], }, DeviceOverview: { DesktopDevicesSummary: { - description: "Desktop devices (Windows and macOS) by join type and compliance status.", + description: 'Desktop devices (Windows and macOS) by join type and compliance status.', nodes: [ // Level 1: Desktop devices to OS - { source: "Desktop devices", target: "Windows", value: 585 }, - { source: "Desktop devices", target: "macOS", value: 75 }, + { source: 'Desktop devices', target: 'Windows', value: 585 }, + { source: 'Desktop devices', target: 'macOS', value: 75 }, // Level 2: Windows to join types - { source: "Windows", target: "Entra joined", value: 285 }, - { source: "Windows", target: "Entra registered", value: 100 }, - { source: "Windows", target: "Entra hybrid joined", value: 200 }, + { source: 'Windows', target: 'Entra joined', value: 285 }, + { source: 'Windows', target: 'Entra registered', value: 100 }, + { source: 'Windows', target: 'Entra hybrid joined', value: 200 }, // Level 3: Windows join types to compliance - { source: "Entra joined", target: "Compliant", value: 171 }, - { source: "Entra joined", target: "Non-compliant", value: 42 }, - { source: "Entra joined", target: "Unmanaged", value: 72 }, - { source: "Entra hybrid joined", target: "Compliant", value: 50 }, - { source: "Entra hybrid joined", target: "Non-compliant", value: 23 }, - { source: "Entra hybrid joined", target: "Unmanaged", value: 127 }, - { source: "Entra registered", target: "Compliant", value: 60 }, - { source: "Entra registered", target: "Non-compliant", value: 40 }, - { source: "Entra registered", target: "Unmanaged", value: 0 }, + { source: 'Entra joined', target: 'Compliant', value: 171 }, + { source: 'Entra joined', target: 'Non-compliant', value: 42 }, + { source: 'Entra joined', target: 'Unmanaged', value: 72 }, + { source: 'Entra hybrid joined', target: 'Compliant', value: 50 }, + { source: 'Entra hybrid joined', target: 'Non-compliant', value: 23 }, + { source: 'Entra hybrid joined', target: 'Unmanaged', value: 127 }, + { source: 'Entra registered', target: 'Compliant', value: 60 }, + { source: 'Entra registered', target: 'Non-compliant', value: 40 }, + { source: 'Entra registered', target: 'Unmanaged', value: 0 }, // Level 2: macOS directly to compliance - { source: "macOS", target: "Compliant", value: 56 }, - { source: "macOS", target: "Non-compliant", value: 15 }, - { source: "macOS", target: "Unmanaged", value: 4 }, + { source: 'macOS', target: 'Compliant', value: 56 }, + { source: 'macOS', target: 'Non-compliant', value: 15 }, + { source: 'macOS', target: 'Unmanaged', value: 4 }, ], }, MobileSummary: { - description: "Mobile devices by compliance status.", + description: 'Mobile devices by compliance status.', nodes: [ - { source: "Mobile devices", target: "Android", value: 105 }, - { source: "Mobile devices", target: "iOS", value: 75 }, - { source: "Android", target: "Android (Company)", value: 72 }, - { source: "Android", target: "Android (Personal)", value: 33 }, - { source: "iOS", target: "iOS (Company)", value: 58 }, - { source: "iOS", target: "iOS (Personal)", value: 17 }, - { source: "Android (Company)", target: "Compliant", value: 60 }, - { source: "Android (Company)", target: "Non-compliant", value: 12 }, - { source: "Android (Personal)", target: "Compliant", value: 10 }, - { source: "Android (Personal)", target: "Non-compliant", value: 23 }, - { source: "iOS (Company)", target: "Compliant", value: 52 }, - { source: "iOS (Company)", target: "Non-compliant", value: 6 }, - { source: "iOS (Personal)", target: "Compliant", value: 11 }, - { source: "iOS (Personal)", target: "Non-compliant", value: 6 }, + { source: 'Mobile devices', target: 'Android', value: 105 }, + { source: 'Mobile devices', target: 'iOS', value: 75 }, + { source: 'Android', target: 'Android (Company)', value: 72 }, + { source: 'Android', target: 'Android (Personal)', value: 33 }, + { source: 'iOS', target: 'iOS (Company)', value: 58 }, + { source: 'iOS', target: 'iOS (Personal)', value: 17 }, + { source: 'Android (Company)', target: 'Compliant', value: 60 }, + { source: 'Android (Company)', target: 'Non-compliant', value: 12 }, + { source: 'Android (Personal)', target: 'Compliant', value: 10 }, + { source: 'Android (Personal)', target: 'Non-compliant', value: 23 }, + { source: 'iOS (Company)', target: 'Compliant', value: 52 }, + { source: 'iOS (Company)', target: 'Non-compliant', value: 6 }, + { source: 'iOS (Personal)', target: 'Compliant', value: 11 }, + { source: 'iOS (Personal)', target: 'Non-compliant', value: 6 }, ], }, ManagedDevices: { @@ -128,4 +128,4 @@ export const dashboardDemoData = { }, }, }, -}; +} From a2d8f1918f67ed864a57e50e55f8fd3f949774a9 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 25 May 2026 21:32:56 +0200 Subject: [PATCH 096/164] react-dom --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 842abc453ddd..331476b49fa7 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "react": "19.2.6", "react-apexcharts": "2.1.0", "react-beautiful-dnd": "13.1.1", - "react-dom": "19.2.5", + "react-dom": "19.2.6", "react-dropzone": "15.0.0", "react-error-boundary": "^6.1.1", "react-hook-form": "^7.72.0", From 7d1c2096f820f505684167d28bf38487534bd5ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 23:09:50 +0000 Subject: [PATCH 097/164] Move EnrollmentProfileTabs from pages to components and update imports Agent-Logs-Url: https://github.com/KelvinTegelaar/CIPP/sessions/bc416d42-34cd-460e-b5cb-57aec78b6c58 Co-authored-by: Zacgoose <107489668+Zacgoose@users.noreply.github.com> --- .../EnrollmentProfileTabs.jsx | 18 +++++++++--------- .../enrollment-profiles/android-enterprise.js | 2 +- .../MEM/enrollment-profiles/apple-ade.js | 2 +- .../endpoint/MEM/enrollment-profiles/index.js | 2 +- .../enrollment-profiles/windows-autopilot.js | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) rename src/{pages/endpoint/MEM/enrollment-profiles => components}/EnrollmentProfileTabs.jsx (95%) diff --git a/src/pages/endpoint/MEM/enrollment-profiles/EnrollmentProfileTabs.jsx b/src/components/EnrollmentProfileTabs.jsx similarity index 95% rename from src/pages/endpoint/MEM/enrollment-profiles/EnrollmentProfileTabs.jsx rename to src/components/EnrollmentProfileTabs.jsx index 73fd35519bd0..882b73b04d9e 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/EnrollmentProfileTabs.jsx +++ b/src/components/EnrollmentProfileTabs.jsx @@ -19,15 +19,15 @@ import { QrCode2, Sync, } from '@mui/icons-material' -import { CippHead } from '../../../../components/CippComponents/CippHead.jsx' -import { CippDataTable } from '../../../../components/CippTable/CippDataTable.js' -import { CippInfoBar } from '../../../../components/CippCards/CippInfoBar.jsx' -import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog.jsx' -import { CippAutopilotProfileDrawer } from '../../../../components/CippComponents/CippAutopilotProfileDrawer.jsx' -import CippJsonView from '../../../../components/CippFormPages/CippJSONView.jsx' -import { ApiGetCall } from '../../../../api/ApiCall.jsx' -import { useDialog } from '../../../../hooks/use-dialog.js' -import { useSettings } from '../../../../hooks/use-settings.js' +import { CippHead } from './CippComponents/CippHead.jsx' +import { CippDataTable } from './CippTable/CippDataTable.js' +import { CippInfoBar } from './CippCards/CippInfoBar.jsx' +import { CippApiDialog } from './CippComponents/CippApiDialog.jsx' +import { CippAutopilotProfileDrawer } from './CippComponents/CippAutopilotProfileDrawer.jsx' +import CippJsonView from './CippFormPages/CippJSONView.jsx' +import { ApiGetCall } from '../api/ApiCall.jsx' +import { useDialog } from '../hooks/use-dialog.js' +import { useSettings } from '../hooks/use-settings.js' const pageTitle = 'Enrollment Profiles' const appleADEPageTitle = 'Apple Enrollment Profiles' diff --git a/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js b/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js index 826afd75f085..58245f4a548a 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js +++ b/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { AndroidEnterpriseEnrollmentProfiles } from './EnrollmentProfileTabs.jsx' +import { AndroidEnterpriseEnrollmentProfiles } from '../../../../components/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' const Page = () => diff --git a/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js b/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js index e6fc7bfad50d..4f4aed94df6f 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js +++ b/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { AppleADEEnrollmentProfiles } from './EnrollmentProfileTabs.jsx' +import { AppleADEEnrollmentProfiles } from '../../../../components/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' const Page = () => diff --git a/src/pages/endpoint/MEM/enrollment-profiles/index.js b/src/pages/endpoint/MEM/enrollment-profiles/index.js index 4c072ed7302d..ec561612656d 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/index.js +++ b/src/pages/endpoint/MEM/enrollment-profiles/index.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { WindowsAutopilotEnrollmentProfiles } from './EnrollmentProfileTabs.jsx' +import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' const Page = () => diff --git a/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js b/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js index 4c072ed7302d..ec561612656d 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js +++ b/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { WindowsAutopilotEnrollmentProfiles } from './EnrollmentProfileTabs.jsx' +import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' const Page = () => From 02c7a4341ca6d9ceb016ed45d10f0e188bce6a26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 23:13:28 +0000 Subject: [PATCH 098/164] Move EnrollmentProfileTabs to CippComponents folder and update imports Agent-Logs-Url: https://github.com/KelvinTegelaar/CIPP/sessions/08f68e6a-d4d1-481c-ba38-76d0ef9af438 Co-authored-by: Zacgoose <107489668+Zacgoose@users.noreply.github.com> --- .../EnrollmentProfileTabs.jsx | 18 +++++++++--------- .../enrollment-profiles/android-enterprise.js | 2 +- .../MEM/enrollment-profiles/apple-ade.js | 2 +- .../endpoint/MEM/enrollment-profiles/index.js | 2 +- .../enrollment-profiles/windows-autopilot.js | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) rename src/components/{ => CippComponents}/EnrollmentProfileTabs.jsx (96%) diff --git a/src/components/EnrollmentProfileTabs.jsx b/src/components/CippComponents/EnrollmentProfileTabs.jsx similarity index 96% rename from src/components/EnrollmentProfileTabs.jsx rename to src/components/CippComponents/EnrollmentProfileTabs.jsx index 882b73b04d9e..4499d1c94962 100644 --- a/src/components/EnrollmentProfileTabs.jsx +++ b/src/components/CippComponents/EnrollmentProfileTabs.jsx @@ -19,15 +19,15 @@ import { QrCode2, Sync, } from '@mui/icons-material' -import { CippHead } from './CippComponents/CippHead.jsx' -import { CippDataTable } from './CippTable/CippDataTable.js' -import { CippInfoBar } from './CippCards/CippInfoBar.jsx' -import { CippApiDialog } from './CippComponents/CippApiDialog.jsx' -import { CippAutopilotProfileDrawer } from './CippComponents/CippAutopilotProfileDrawer.jsx' -import CippJsonView from './CippFormPages/CippJSONView.jsx' -import { ApiGetCall } from '../api/ApiCall.jsx' -import { useDialog } from '../hooks/use-dialog.js' -import { useSettings } from '../hooks/use-settings.js' +import { CippHead } from './CippHead.jsx' +import { CippDataTable } from '../CippTable/CippDataTable.js' +import { CippInfoBar } from '../CippCards/CippInfoBar.jsx' +import { CippApiDialog } from './CippApiDialog.jsx' +import { CippAutopilotProfileDrawer } from './CippAutopilotProfileDrawer.jsx' +import CippJsonView from '../CippFormPages/CippJSONView.jsx' +import { ApiGetCall } from '../../api/ApiCall.jsx' +import { useDialog } from '../../hooks/use-dialog.js' +import { useSettings } from '../../hooks/use-settings.js' const pageTitle = 'Enrollment Profiles' const appleADEPageTitle = 'Apple Enrollment Profiles' diff --git a/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js b/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js index 58245f4a548a..88f86700374a 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js +++ b/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { AndroidEnterpriseEnrollmentProfiles } from '../../../../components/EnrollmentProfileTabs.jsx' +import { AndroidEnterpriseEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' const Page = () => diff --git a/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js b/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js index 4f4aed94df6f..2225de59f0a8 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js +++ b/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { AppleADEEnrollmentProfiles } from '../../../../components/EnrollmentProfileTabs.jsx' +import { AppleADEEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' const Page = () => diff --git a/src/pages/endpoint/MEM/enrollment-profiles/index.js b/src/pages/endpoint/MEM/enrollment-profiles/index.js index ec561612656d..a2cf307e80d2 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/index.js +++ b/src/pages/endpoint/MEM/enrollment-profiles/index.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/EnrollmentProfileTabs.jsx' +import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' const Page = () => diff --git a/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js b/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js index ec561612656d..a2cf307e80d2 100644 --- a/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js +++ b/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js @@ -1,6 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/EnrollmentProfileTabs.jsx' +import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' const Page = () => From 1e59d2d43fab463a709d1dac2477f45cfacdfae6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 26 May 2026 11:54:57 +0800 Subject: [PATCH 099/164] Update ListTests.json --- Tests/Shapes/ListTests.json | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/Shapes/ListTests.json b/Tests/Shapes/ListTests.json index a4553bd14c70..e79c73d8eb9f 100644 --- a/Tests/Shapes/ListTests.json +++ b/Tests/Shapes/ListTests.json @@ -119,6 +119,7 @@ "ExoSafeLinksRules": "number", "ExoSharingPolicy": "number", "ExoTenantAllowBlockList": "number", + "ExoTransportConfig": "number", "ExoTransportRules": "number", "Groups": "number", "Guests": "number", From 1cd1ef7223672170bdce1fffe88d8bb4ddb903d9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 26 May 2026 12:30:37 +0800 Subject: [PATCH 100/164] Update AuditLogTemplates.json --- src/data/AuditLogTemplates.json | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/data/AuditLogTemplates.json b/src/data/AuditLogTemplates.json index 63df852bd318..68f87b52bdf6 100644 --- a/src/data/AuditLogTemplates.json +++ b/src/data/AuditLogTemplates.json @@ -439,18 +439,10 @@ "label": "updated user" } }, - { - "Property": { "value": "String", "label": "SecuredAccessPassData" }, - "Operator": { "value": "ne", "label": "Not Equals to" }, - "Input": { - "value": "[]", - "label": "[]" - } - }, { "Property": { "value": "String", "label": "SecuredAccessPassData" }, "Operator": { "value": "like", "label": "Like" }, - "Input": { "value": "[*]" } + "Input": { "value": "*PassId*" } } ] } From ca150a28cfcb3922d6de42cb4db54f2afcdf6e0e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 26 May 2026 15:08:56 +0800 Subject: [PATCH 101/164] Better display standards that are missing licenses to be able to work --- src/pages/tenant/manage/applied-standards.js | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/pages/tenant/manage/applied-standards.js b/src/pages/tenant/manage/applied-standards.js index 376507ec4017..d69b3aebc81c 100644 --- a/src/pages/tenant/manage/applied-standards.js +++ b/src/pages/tenant/manage/applied-standards.js @@ -36,6 +36,7 @@ import { Construction, Schedule, Check, + Warning, } from '@mui/icons-material' import standards from '../../../data/standards.json' import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog' @@ -248,6 +249,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, @@ -372,6 +374,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, // Use the template settings object instead of true @@ -504,6 +507,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, @@ -619,6 +623,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, // Use the template settings object instead of true @@ -727,6 +732,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: { displayName }, @@ -867,6 +873,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, @@ -1035,6 +1042,11 @@ const Page = () => { } } + // If the tenant is missing the required license, treat as compliant + if (standardObject?.LicenseAvailable === false) { + isCompliant = true + } + // Determine compliance status text based on reporting flag const complianceStatus = reportingDisabled ? 'Reporting Disabled' @@ -1061,6 +1073,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: standardSettings, @@ -1155,6 +1168,7 @@ const Page = () => { TemplateId: standardObject?.TemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, }, standardValue: { templateId: itemTemplateId, @@ -1969,6 +1983,15 @@ const Page = () => { + {standard.currentTenantValue?.LicenseAvailable === false ? ( + }> + {typeof standard.currentTenantValue?.Value === 'string' && + standard.currentTenantValue.Value.startsWith('License Missing:') + ? standard.currentTenantValue.Value + : 'This tenant does not have the required licenses for this standard'} + + ) : ( + <> {/* Show Expected Configuration with property-by-property breakdown */} {standard.currentTenantValue?.ExpectedValue !== undefined ? ( @@ -2073,6 +2096,8 @@ const Page = () => { sx={{ mr: 1 }} /> + + )}
@@ -2170,6 +2195,15 @@ const Page = () => { + {standard.currentTenantValue?.LicenseAvailable === false ? ( + }> + {typeof standard.currentTenantValue?.Value === 'string' && + standard.currentTenantValue.Value.startsWith('License Missing:') + ? standard.currentTenantValue.Value + : 'This tenant does not have the required licenses for this standard'} + + ) : ( + <> {/* Existing tenant comparison content */} {typeof standard.currentTenantValue?.Value === 'object' && standard.currentTenantValue?.Value !== null ? ( @@ -2931,6 +2965,8 @@ const Page = () => { )} )} + + )} From f3c8a79e42e97ad5b0cad9889ce15e4863f6bef8 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 26 May 2026 15:09:04 +0800 Subject: [PATCH 102/164] Update yarn.lock --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6ffd82077dd1..f679e9ff64b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6558,10 +6558,10 @@ react-colorful@^5.6.1: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== -react-dom@19.2.5: - version "19.2.5" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.5.tgz#b8768b10837d0b8e9ca5b9e2d58dff3d880ea25e" - integrity sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag== +react-dom@19.2.6: + version "19.2.6" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.6.tgz#44a81b0bcca22da814c00847d09d01c8615529b7" + integrity sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g== dependencies: scheduler "^0.27.0" From d28e8ebfaa9517e5f2134bb1c339d294096dca91 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 26 May 2026 23:33:43 +0800 Subject: [PATCH 103/164] user sync --- .../CippSettings/CippRoleAddEdit.jsx | 7 + .../CippSettings/CippUserManagement.jsx | 163 +++++++++++++++--- .../cipp/advanced/super-admin/cipp-users.js | 11 +- 3 files changed, 148 insertions(+), 33 deletions(-) diff --git a/src/components/CippSettings/CippRoleAddEdit.jsx b/src/components/CippSettings/CippRoleAddEdit.jsx index 757215cd0f49..75192d394004 100644 --- a/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/src/components/CippSettings/CippRoleAddEdit.jsx @@ -41,6 +41,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const formControl = useForm({ mode: "onChange", + defaultValues: { + allowedTenants: [], + blockedTenants: [], + BlockedEndpoints: [], + IPRange: [], + Permissions: {}, + }, }); const formState = useFormState({ control: formControl.control }); diff --git a/src/components/CippSettings/CippUserManagement.jsx b/src/components/CippSettings/CippUserManagement.jsx index ab4be74c1b2b..b1d84c2f0b70 100644 --- a/src/components/CippSettings/CippUserManagement.jsx +++ b/src/components/CippSettings/CippUserManagement.jsx @@ -26,26 +26,30 @@ import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; export const CippUserManagement = () => { const [dialogOpen, setDialogOpen] = useState(false); const [editingUser, setEditingUser] = useState(null); + const [bulkEditUsers, setBulkEditUsers] = useState(null); const formControl = useForm({ mode: "onChange", defaultValues: { UPN: "", Roles: [] }, }); - const rolesQuery = ApiGetCall({ - url: "/api/ListCustomRole", - queryKey: "customRoleList", + const usersQuery = ApiGetCall({ + url: "/api/ListCIPPUsers", + queryKey: "cippUsersList", }); const userAction = ApiPostCall({ relatedQueryKeys: ["cippUsersList"], }); - const allRoles = Array.isArray(rolesQuery.data) ? rolesQuery.data : []; + const pageData = usersQuery.data?.pages?.[0] ?? usersQuery.data ?? {}; + const allRoles = Array.isArray(pageData.AvailableRoles) ? pageData.AvailableRoles : []; const roleOptions = allRoles.map((r) => ({ label: `${r.RoleName} (${r.Type})`, value: r.RoleName, })); + const existingUsers = Array.isArray(pageData.Users) ? pageData.Users : []; + const existingUpns = new Set(existingUsers.map((u) => u.UPN.toLowerCase())); const openAddDialog = () => { setEditingUser(null); @@ -55,33 +59,36 @@ export const CippUserManagement = () => { const openEditDialog = (row) => { setEditingUser(row); - const currentRoles = (row.Roles ?? []).map((r) => { + // Show only manual roles for editing — auto roles are managed by sync + const editableRoles = (row.ManualRoles ?? row.Roles ?? []).map((r) => { const match = roleOptions.find((opt) => opt.value === r); return match ?? { label: r, value: r }; }); - formControl.reset({ UPN: row.UPN, Roles: currentRoles }); + formControl.reset({ UPN: row.UPN, Roles: editableRoles }); setDialogOpen(true); }; const handleSaveUser = (data) => { const roles = Array.isArray(data.Roles) ? data.Roles.map((r) => r.value ?? r) : [data.Roles]; - userAction.mutate( - { + + // Bulk edit: apply same roles to all selected users + const upns = bulkEditUsers ? bulkEditUsers.map((u) => u.UPN) : [data.UPN]; + + upns.forEach((upn) => { + userAction.mutate({ url: "/api/ExecCIPPUsers", data: { Action: "AddUpdate", - UPN: data.UPN, + UPN: upn, Roles: roles, }, - }, - { - onSuccess: () => { - formControl.reset({ UPN: "", Roles: [] }); - setEditingUser(null); - setDialogOpen(false); - }, - } - ); + }); + }); + + formControl.reset({ UPN: "", Roles: [] }); + setEditingUser(null); + setBulkEditUsers(null); + setDialogOpen(false); }; const actions = [ @@ -94,6 +101,12 @@ export const CippUserManagement = () => { ), noConfirm: true, customFunction: (row) => openEditDialog(row), + customBulkHandler: ({ data, clearSelection }) => { + setBulkEditUsers(data); + setEditingUser(null); + formControl.reset({ UPN: "", Roles: [] }); + setDialogOpen(true); + }, }, { label: "Delete User", @@ -113,6 +126,19 @@ export const CippUserManagement = () => { }, ]; + const sourceLabel = (source) => { + switch (source) { + case "Auto": + return "Auto-synced from Entra groups"; + case "Both": + return "Auto-synced + Manual"; + case "Manual": + return "Manually assigned"; + default: + return source || "—"; + } + }; + const offCanvas = { children: (row) => ( @@ -123,9 +149,16 @@ export const CippUserManagement = () => { {row.UPN} + + + Source + + {sourceLabel(row.Source)} + + - Assigned Roles + Effective Roles {(row.Roles ?? []).map((role, idx) => ( @@ -133,6 +166,49 @@ export const CippUserManagement = () => { ))} + {(row.ManualRoles ?? []).length > 0 && ( + <> + + + + Manual Roles + + + {row.ManualRoles.map((role, idx) => ( + + ))} + + + + )} + {(row.AutoRoles ?? []).length > 0 && ( + <> + + + + Auto Roles (from Entra groups) + + + {row.AutoRoles.map((role, idx) => ( + + ))} + + + + )} + {row.LastSync && ( + <> + + + + Last Synced + + + {new Date(row.LastSync).toLocaleString()} + + + + )} ), }; @@ -161,7 +237,7 @@ export const CippUserManagement = () => { dataKey: "Users", }} queryKey="cippUsersList" - simpleColumns={["UPN", "Roles"]} + simpleColumns={["UPN", "Roles", "Source"]} offCanvas={offCanvas} /> @@ -169,26 +245,57 @@ export const CippUserManagement = () => { setDialogOpen(false)} + onClose={() => { + setDialogOpen(false); + setBulkEditUsers(null); + }} maxWidth="sm" fullWidth > - {editingUser ? `Edit Roles — ${editingUser.UPN}` : "Add CIPP User"} + + {bulkEditUsers + ? `Bulk Edit Roles — ${bulkEditUsers.length} users` + : editingUser + ? `Edit Roles — ${editingUser.UPN}` + : "Add CIPP User"} + - {editingUser - ? "Update the roles assigned to this user." - : "Add a user by their email address (UPN) and assign one or more roles. If the user already exists, their roles will be updated."} + {bulkEditUsers + ? `Set the manual roles for ${bulkEditUsers.length} selected users. This will replace their existing manual roles. Auto-synced roles from Entra groups will not be affected.` + : editingUser + ? "Update the manually assigned roles for this user. Auto-synced roles from Entra groups are managed separately and will not be affected." + : "Add a user by their email address (UPN) and assign one or more roles. These are stored as manual assignments and won't be overwritten by the automatic Entra group sync."} - {!editingUser && ( + {bulkEditUsers && ( + + + Selected Users + + + {bulkEditUsers.map((u, idx) => ( + + ))} + + + )} + {!editingUser && !bulkEditUsers && ( { + if (existingUpns.has(value?.trim()?.toLowerCase())) { + return "This user already exists. Use Edit Roles to update their permissions."; + } + return true; + }, + }} /> )} { - + - + + From 1b7797afd4f4c55e6a44157a87914daf8110068f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 27 May 2026 16:29:15 +0800 Subject: [PATCH 106/164] Update standards.json --- src/data/standards.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index b0fd2eabf081..1f40d86e0234 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -6822,7 +6822,7 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.ConnectWindows", - "label": "Connect Windows 10.0.15063+ to MDE", + "label": "Connect Windows 10.0.15063+ to MDE (Note: enabling this forces 'Block Windows if partner data unavailable' to on)", "defaultValue": false }, { @@ -6834,7 +6834,7 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.windowsDeviceBlockedOnMissingPartnerData", - "label": "Block Windows if partner data unavailable", + "label": "Block Windows if partner data unavailable (Note: Microsoft enforces this to on when Connect Windows 10.0.15063+ to MDE is on)", "defaultValue": false }, { From de70889fe1c4e157ac25eb1ef43751add88056fa Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 27 May 2026 14:39:44 +0200 Subject: [PATCH 107/164] smart lockout standard --- src/data/standards.json | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index 1f40d86e0234..d78651f45ef7 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -7293,5 +7293,49 @@ "addedDate": "2026-05-25", "powershellEquivalent": "Graph API PATCH /policies/authenticationMethodsPolicy/authenticationMethodConfigurations/fido2", "recommendedBy": ["CIPP"] + }, + { + "name": "standards.SmartLockout", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "**Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode.", + "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced).", + "addedComponent": [ + { + "type": "number", + "name": "standards.SmartLockout.LockoutDurationInSeconds", + "label": "Lockout Duration (seconds)", + "default": 60, + "required": true + }, + { + "type": "number", + "name": "standards.SmartLockout.LockoutThreshold", + "label": "Lockout Threshold (failed attempts)", + "default": 10, + "required": true + }, + { + "type": "switch", + "name": "standards.SmartLockout.EnableBannedPasswordCheckOnPremises", + "label": "Enable On-Premises Password Protection" + }, + { + "type": "radio", + "name": "standards.SmartLockout.BannedPasswordCheckOnPremisesMode", + "label": "On-Premises Mode", + "options": [ + { "label": "Audit", "value": "Audit" }, + { "label": "Enforced", "value": "Enforced" } + ] + } + ], + "label": "Configure Entra ID Smart Lockout", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-27", + "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", + "recommendedBy": ["CIS"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] } ] From 0e527e50d858628dbe0943c33c0b67b18949d612 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 27 May 2026 17:03:29 +0200 Subject: [PATCH 108/164] Sharepoint management functionality. --- src/data/standards.json | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index d78651f45ef7..cdb04909bc20 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -7337,5 +7337,48 @@ "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", "recommendedBy": ["CIS"], "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] + }, + { + "name": "standards.SPOVersionControl", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Configures SharePoint Online file versioning to either use automatic version trimming managed by Microsoft, or enforce a fixed major version limit with optional version expiration.", + "docsDescription": "Configures the SharePoint Online tenant-level file versioning policy. When automatic version trimming is enabled, Microsoft intelligently manages version cleanup. When disabled, you can set a fixed maximum number of major versions to retain and optionally expire versions after a specified number of days. This helps manage storage consumption while preserving version history as needed.", + "executiveText": "Controls how SharePoint Online manages file version history at the tenant level. Automatic trimming lets Microsoft optimize storage by cleaning up old versions intelligently. Manual limits give administrators precise control over the maximum number of versions retained and their expiration, ensuring predictable storage usage and compliance with data retention policies.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.SPOVersionControl.EnableAutoTrim", + "label": "Enable Automatic Version Trimming (Microsoft managed)" + }, + { + "type": "number", + "name": "standards.SPOVersionControl.MajorVersionLimit", + "label": "Maximum Major Versions (when auto trim is off)", + "default": 50 + }, + { + "type": "number", + "name": "standards.SPOVersionControl.ExpireVersionsAfterDays", + "label": "Expire Versions After Days (0 = never, when auto trim is off)", + "default": 0 + }, + { + "type": "switch", + "name": "standards.SPOVersionControl.ApplyToExistingSites", + "label": "Apply to all existing sites and document libraries" + } + ], + "label": "Set SharePoint File Version Limits", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-27", + "powershellEquivalent": "Set-SPOTenant -EnableAutoExpirationVersionTrim $true or Set-SPOTenant -EnableAutoExpirationVersionTrim $false -MajorVersionLimit 50 -ExpireVersionsAfterDays 365", + "recommendedBy": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + } } ] From 5709f85661a047dad29216739acccf6326c9231b Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 27 May 2026 17:11:46 +0200 Subject: [PATCH 109/164] fix: update terminology from "Temporary Access Password" to "Temporary Access Pass" Fixes https://github.com/KelvinTegelaar/CIPP/issues/6060 --- src/components/CippComponents/CippUserActions.jsx | 4 ++-- src/data/standards.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index c1af17ab13e6..e635c5fa988f 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -418,7 +418,7 @@ export const useCippUserActions = () => { }, { //tested - label: 'Create Temporary Access Password', + label: 'Create Temporary Access Pass', type: 'POST', icon: , url: '/api/ExecCreateTAP', @@ -443,7 +443,7 @@ export const useCippUserActions = () => { }, ], confirmText: - 'Are you sure you want to create a Temporary Access Password for [userPrincipalName]?', + 'Are you sure you want to create a Temporary Access Pass for [userPrincipalName]?', multiPost: false, condition: () => canWriteUser, }, diff --git a/src/data/standards.json b/src/data/standards.json index cdb04909bc20..dbe64432f9d5 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -753,8 +753,8 @@ "tag": [], "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", - "docsDescription": "Enables Temporary Password generation for the tenant.", - "executiveText": "Enables temporary access passwords that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passwords provide a secure way to restore access without compromising long-term security policies.", + "docsDescription": "Enables Temporary Access Pass generation for the tenant.", + "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ { "type": "autoComplete", @@ -768,7 +768,7 @@ ] } ], - "label": "Enable Temporary Access Passwords", + "label": "Enable Temporary Access Passes (TAP)", "impact": "Low Impact", "impactColour": "info", "addedDate": "2022-03-15", From bf6056baf362471dba4ecbb599e0b1c85091b8d0 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 27 May 2026 20:24:47 +0200 Subject: [PATCH 110/164] Add version cleanup --- src/pages/teams-share/sharepoint/index.js | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index cf1353f52b7f..657f9b8e60cf 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -9,12 +9,15 @@ import { AdminPanelSettings, NoAccounts, Delete, + CleaningServices, } from '@mui/icons-material' import Link from 'next/link' import { Stack } from '@mui/system' import { CippDataTable } from '../../../components/CippTable/CippDataTable' import { useSettings } from '../../../hooks/use-settings' import { useCippReportDB } from '../../../components/CippComponents/CippReportDBControls' +import CippFormComponent from '../../../components/CippComponents/CippFormComponent' +import { CippFormCondition } from '../../../components/CippComponents/CippFormCondition' const Page = () => { const pageTitle = 'SharePoint Sites' @@ -202,6 +205,71 @@ const Page = () => { color: 'error', multiPost: false, }, + { + label: 'Start Version Cleanup Job', + type: 'POST', + icon: , + url: '/api/ExecSPOVersionCleanup', + data: { + SiteUrl: 'webUrl', + }, + confirmText: + 'Start a file version cleanup job for [displayName]. This will trim old file versions based on the selected mode.', + children: ({ formHook }) => ( + <> + + + + + + + + + ), + defaultvalues: { + BatchDeleteMode: '2', + }, + customDataformatter: (row, action, formData) => ({ + tenantFilter: row.Tenant ?? tenantFilter, + SiteUrl: row.webUrl, + BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10), + DeleteOlderThanDays: formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, + MajorVersionLimit: formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, + }), + multiPost: false, + }, ] const offCanvas = { From 635548afd1d988518d8c392413828aec7e39eda5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 27 May 2026 20:25:01 +0200 Subject: [PATCH 111/164] Add version cleanup --- src/pages/teams-share/sharepoint/index.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index 657f9b8e60cf..695b2bef7684 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -224,7 +224,10 @@ const Page = () => { formControl={formHook} options={[ { label: 'Sync Policy — apply site version policy to existing versions', value: '2' }, - { label: 'Delete Older Than Days — remove versions older than a set number of days', value: '0' }, + { + label: 'Delete Older Than Days — remove versions older than a set number of days', + value: '0', + }, { label: 'Count Limits — keep a maximum number of major versions', value: '1' }, ]} /> @@ -265,8 +268,10 @@ const Page = () => { tenantFilter: row.Tenant ?? tenantFilter, SiteUrl: row.webUrl, BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10), - DeleteOlderThanDays: formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, - MajorVersionLimit: formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, + DeleteOlderThanDays: + formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, + MajorVersionLimit: + formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, }), multiPost: false, }, From 7a40854272a60617d57a28570406953a9cca913c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 27 May 2026 20:37:02 +0200 Subject: [PATCH 112/164] fix query keys --- src/pages/security/compliance/dlp/index.js | 126 ++++++++--------- src/pages/security/compliance/labels/index.js | 106 ++++++++------- .../security/compliance/retention/index.js | 128 +++++++++--------- src/pages/security/compliance/sit/index.js | 74 +++++----- 4 files changed, 221 insertions(+), 213 deletions(-) diff --git a/src/pages/security/compliance/dlp/index.js b/src/pages/security/compliance/dlp/index.js index 750cfe6ade53..5e09520f5579 100644 --- a/src/pages/security/compliance/dlp/index.js +++ b/src/pages/security/compliance/dlp/index.js @@ -1,99 +1,101 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { Book, Block, Check } from "@mui/icons-material"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { CippDeployCompliancePolicyDrawer } from "../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx"; -import { PermissionButton } from "../../../../utils/permissions.js"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { Book, Block, Check } from '@mui/icons-material' +import { TrashIcon } from '@heroicons/react/24/outline' +import { CippDeployCompliancePolicyDrawer } from '../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx' +import { PermissionButton } from '../../../../utils/permissions.js' +import { useSettings } from '../../../../hooks/use-settings' const Page = () => { - const pageTitle = "DLP Compliance Policies"; - const apiUrl = "/api/ListDlpCompliancePolicy"; - const cardButtonPermissions = ["Security.DlpCompliancePolicy.ReadWrite"]; + const pageTitle = 'DLP Compliance Policies' + const apiUrl = '/api/ListDlpCompliancePolicy' + const tenantFilter = useSettings().currentTenant + const cardButtonPermissions = ['Security.DlpCompliancePolicy.ReadWrite'] const actions = [ { - label: "Create template based on policy", - type: "POST", + label: 'Create template based on policy', + type: 'POST', icon: , - url: "/api/AddDlpCompliancePolicyTemplate", + url: '/api/AddDlpCompliancePolicyTemplate', dataFunction: (data) => { - return { ...data }; + return { ...data } }, - confirmText: "Are you sure you want to create a template based on this DLP policy?", + confirmText: 'Are you sure you want to create a template based on this DLP policy?', }, { - label: "Enable Policy", - type: "POST", + label: 'Enable Policy', + type: 'POST', icon: , - url: "/api/EditDlpCompliancePolicy", + url: '/api/EditDlpCompliancePolicy', data: { - State: "!enable", - Identity: "Name", + State: '!enable', + Identity: 'Name', }, - confirmText: "Are you sure you want to enable this DLP policy?", + confirmText: 'Are you sure you want to enable this DLP policy?', condition: (row) => row.Enabled === false, }, { - label: "Disable Policy", - type: "POST", + label: 'Disable Policy', + type: 'POST', icon: , - url: "/api/EditDlpCompliancePolicy", + url: '/api/EditDlpCompliancePolicy', data: { - State: "!disable", - Identity: "Name", + State: '!disable', + Identity: 'Name', }, - confirmText: "Are you sure you want to disable this DLP policy?", + confirmText: 'Are you sure you want to disable this DLP policy?', condition: (row) => row.Enabled === true, }, { - label: "Delete Policy", - type: "POST", + label: 'Delete Policy', + type: 'POST', icon: , - url: "/api/RemoveDlpCompliancePolicy", + url: '/api/RemoveDlpCompliancePolicy', data: { - Identity: "Name", + Identity: 'Name', }, - confirmText: "Are you sure you want to delete this DLP policy?", - color: "danger", + confirmText: 'Are you sure you want to delete this DLP policy?', + color: 'danger', }, - ]; + ] const offCanvas = { extendedInfoFields: [ - "Name", - "Comment", - "Mode", - "Enabled", - "Workload", - "ExchangeLocation", - "SharePointLocation", - "OneDriveLocation", - "TeamsLocation", - "EndpointDlpLocation", - "RuleCount", - "CreatedBy", - "WhenCreatedUTC", - "WhenChangedUTC", + 'Name', + 'Comment', + 'Mode', + 'Enabled', + 'Workload', + 'ExchangeLocation', + 'SharePointLocation', + 'OneDriveLocation', + 'TeamsLocation', + 'EndpointDlpLocation', + 'RuleCount', + 'CreatedBy', + 'WhenCreatedUTC', + 'WhenChangedUTC', ], actions: actions, - }; + } const simpleColumns = [ - "Name", - "Mode", - "Enabled", - "Workload", - "RuleCount", - "CreatedBy", - "WhenCreatedUTC", - "WhenChangedUTC", - ]; + 'Name', + 'Mode', + 'Enabled', + 'Workload', + 'RuleCount', + 'CreatedBy', + 'WhenCreatedUTC', + 'WhenChangedUTC', + ] return ( { /> } /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/security/compliance/labels/index.js b/src/pages/security/compliance/labels/index.js index e35ff5942b0f..998865acdc87 100644 --- a/src/pages/security/compliance/labels/index.js +++ b/src/pages/security/compliance/labels/index.js @@ -1,78 +1,80 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { Book } from "@mui/icons-material"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { CippDeployCompliancePolicyDrawer } from "../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx"; -import { PermissionButton } from "../../../../utils/permissions.js"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { Book } from '@mui/icons-material' +import { TrashIcon } from '@heroicons/react/24/outline' +import { CippDeployCompliancePolicyDrawer } from '../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx' +import { PermissionButton } from '../../../../utils/permissions.js' +import { useSettings } from '../../../../hooks/use-settings' const Page = () => { - const pageTitle = "Sensitivity Labels"; - const apiUrl = "/api/ListSensitivityLabel"; - const cardButtonPermissions = ["Security.SensitivityLabel.ReadWrite"]; + const pageTitle = 'Sensitivity Labels' + const apiUrl = '/api/ListSensitivityLabel' + const tenantFilter = useSettings().currentTenant + const cardButtonPermissions = ['Security.SensitivityLabel.ReadWrite'] const actions = [ { - label: "Create template based on label", - type: "POST", + label: 'Create template based on label', + type: 'POST', icon: , - url: "/api/AddSensitivityLabelTemplate", + url: '/api/AddSensitivityLabelTemplate', dataFunction: (data) => { - return { ...data }; + return { ...data } }, - confirmText: "Are you sure you want to create a template based on this sensitivity label?", + confirmText: 'Are you sure you want to create a template based on this sensitivity label?', }, { - label: "Delete Label", - type: "POST", + label: 'Delete Label', + type: 'POST', icon: , - url: "/api/RemoveSensitivityLabel", + url: '/api/RemoveSensitivityLabel', data: { - Identity: "Guid", + Identity: 'Guid', }, confirmText: - "Are you sure you want to delete this sensitivity label? Labels currently published to users will be removed from policies.", - color: "danger", + 'Are you sure you want to delete this sensitivity label? Labels currently published to users will be removed from policies.', + color: 'danger', }, - ]; + ] const offCanvas = { extendedInfoFields: [ - "DisplayName", - "Name", - "Comment", - "Tooltip", - "ParentId", - "ContentType", - "EncryptionEnabled", - "EncryptionProtectionType", - "ContentMarkingHeaderEnabled", - "ContentMarkingFooterEnabled", - "ContentMarkingWatermarkEnabled", - "SiteAndGroupProtectionEnabled", - "Priority", - "Disabled", - "PublishedInPolicies", + 'DisplayName', + 'Name', + 'Comment', + 'Tooltip', + 'ParentId', + 'ContentType', + 'EncryptionEnabled', + 'EncryptionProtectionType', + 'ContentMarkingHeaderEnabled', + 'ContentMarkingFooterEnabled', + 'ContentMarkingWatermarkEnabled', + 'SiteAndGroupProtectionEnabled', + 'Priority', + 'Disabled', + 'PublishedInPolicies', ], actions: actions, - }; + } const simpleColumns = [ - "DisplayName", - "Name", - "ContentType", - "EncryptionEnabled", - "ContentMarkingHeaderEnabled", - "ContentMarkingWatermarkEnabled", - "SiteAndGroupProtectionEnabled", - "Priority", - "Disabled", - ]; + 'DisplayName', + 'Name', + 'ContentType', + 'EncryptionEnabled', + 'ContentMarkingHeaderEnabled', + 'ContentMarkingWatermarkEnabled', + 'SiteAndGroupProtectionEnabled', + 'Priority', + 'Disabled', + ] return ( { /> } /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/security/compliance/retention/index.js b/src/pages/security/compliance/retention/index.js index 962301013f29..940d88b4eb0a 100644 --- a/src/pages/security/compliance/retention/index.js +++ b/src/pages/security/compliance/retention/index.js @@ -1,98 +1,100 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { Book, Block, Check } from "@mui/icons-material"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { CippDeployCompliancePolicyDrawer } from "../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx"; -import { PermissionButton } from "../../../../utils/permissions.js"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { Book, Block, Check } from '@mui/icons-material' +import { TrashIcon } from '@heroicons/react/24/outline' +import { CippDeployCompliancePolicyDrawer } from '../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx' +import { PermissionButton } from '../../../../utils/permissions.js' +import { useSettings } from '../../../../hooks/use-settings' const Page = () => { - const pageTitle = "Purview Retention Policies"; - const apiUrl = "/api/ListRetentionCompliancePolicy"; - const cardButtonPermissions = ["Security.RetentionCompliancePolicy.ReadWrite"]; + const pageTitle = 'Purview Retention Policies' + const apiUrl = '/api/ListRetentionCompliancePolicy' + const tenantFilter = useSettings().currentTenant + const cardButtonPermissions = ['Security.RetentionCompliancePolicy.ReadWrite'] const actions = [ { - label: "Create template based on policy", - type: "POST", + label: 'Create template based on policy', + type: 'POST', icon: , - url: "/api/AddRetentionCompliancePolicyTemplate", - data: { Identity: "Name" }, - confirmText: "Are you sure you want to create a template based on this retention policy?", + url: '/api/AddRetentionCompliancePolicyTemplate', + data: { Identity: 'Name' }, + confirmText: 'Are you sure you want to create a template based on this retention policy?', }, { - label: "Enable Policy", - type: "POST", + label: 'Enable Policy', + type: 'POST', icon: , - url: "/api/EditRetentionCompliancePolicy", + url: '/api/EditRetentionCompliancePolicy', data: { - State: "!enable", - Identity: "Name", + State: '!enable', + Identity: 'Name', }, - confirmText: "Are you sure you want to enable this retention policy?", + confirmText: 'Are you sure you want to enable this retention policy?', condition: (row) => row.Enabled === false, }, { - label: "Disable Policy", - type: "POST", + label: 'Disable Policy', + type: 'POST', icon: , - url: "/api/EditRetentionCompliancePolicy", + url: '/api/EditRetentionCompliancePolicy', data: { - State: "!disable", - Identity: "Name", + State: '!disable', + Identity: 'Name', }, - confirmText: "Are you sure you want to disable this retention policy?", + confirmText: 'Are you sure you want to disable this retention policy?', condition: (row) => row.Enabled === true, }, { - label: "Delete Policy", - type: "POST", + label: 'Delete Policy', + type: 'POST', icon: , - url: "/api/RemoveRetentionCompliancePolicy", + url: '/api/RemoveRetentionCompliancePolicy', data: { - Identity: "Name", + Identity: 'Name', }, - confirmText: "Are you sure you want to delete this retention policy?", - color: "danger", + confirmText: 'Are you sure you want to delete this retention policy?', + color: 'danger', }, - ]; + ] const offCanvas = { extendedInfoFields: [ - "Name", - "Comment", - "Enabled", - "Workload", - "RestrictiveRetention", - "ExchangeLocation", - "SharePointLocation", - "OneDriveLocation", - "ModernGroupLocation", - "TeamsChannelLocation", - "TeamsChatLocation", - "RuleCount", - "CreatedBy", - "WhenCreatedUTC", - "WhenChangedUTC", + 'Name', + 'Comment', + 'Enabled', + 'Workload', + 'RestrictiveRetention', + 'ExchangeLocation', + 'SharePointLocation', + 'OneDriveLocation', + 'ModernGroupLocation', + 'TeamsChannelLocation', + 'TeamsChatLocation', + 'RuleCount', + 'CreatedBy', + 'WhenCreatedUTC', + 'WhenChangedUTC', ], actions: actions, - }; + } const simpleColumns = [ - "Name", - "Enabled", - "Workload", - "RuleCount", - "RestrictiveRetention", - "CreatedBy", - "WhenCreatedUTC", - "WhenChangedUTC", - ]; + 'Name', + 'Enabled', + 'Workload', + 'RuleCount', + 'RestrictiveRetention', + 'CreatedBy', + 'WhenCreatedUTC', + 'WhenChangedUTC', + ] return ( { /> } /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/security/compliance/sit/index.js b/src/pages/security/compliance/sit/index.js index 3101f0502218..43039cdba7fb 100644 --- a/src/pages/security/compliance/sit/index.js +++ b/src/pages/security/compliance/sit/index.js @@ -1,57 +1,59 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { CippDeployCompliancePolicyDrawer } from "../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx"; -import { PermissionButton } from "../../../../utils/permissions.js"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { TrashIcon } from '@heroicons/react/24/outline' +import { CippDeployCompliancePolicyDrawer } from '../../../../components/CippComponents/CippDeployCompliancePolicyDrawer.jsx' +import { PermissionButton } from '../../../../utils/permissions.js' +import { useSettings } from '../../../../hooks/use-settings' const Page = () => { - const pageTitle = "Sensitive Information Types"; - const apiUrl = "/api/ListSensitiveInfoType"; - const cardButtonPermissions = ["Security.SensitiveInfoType.ReadWrite"]; + const pageTitle = 'Sensitive Information Types' + const apiUrl = '/api/ListSensitiveInfoType' + const tenantFilter = useSettings().currentTenant + const cardButtonPermissions = ['Security.SensitiveInfoType.ReadWrite'] const actions = [ { - label: "Delete SIT", - type: "POST", + label: 'Delete SIT', + type: 'POST', icon: , - url: "/api/RemoveSensitiveInfoType", + url: '/api/RemoveSensitiveInfoType', data: { - Identity: "Name", + Identity: 'Name', }, confirmText: - "Are you sure you want to delete this Sensitive Information Type? Built-in Microsoft types cannot be deleted.", - color: "danger", + 'Are you sure you want to delete this Sensitive Information Type? Built-in Microsoft types cannot be deleted.', + color: 'danger', }, - ]; + ] const offCanvas = { extendedInfoFields: [ - "Name", - "Description", - "Publisher", - "Recommended", - "RulePackId", - "RulePackVersion", - "State", - "Type", + 'Name', + 'Description', + 'Publisher', + 'Recommended', + 'RulePackId', + 'RulePackVersion', + 'State', + 'Type', ], actions: actions, - }; + } const simpleColumns = [ - "Name", - "Publisher", - "Description", - "Recommended", - "RulePackVersion", - "State", - ]; + 'Name', + 'Publisher', + 'Description', + 'Recommended', + 'RulePackVersion', + 'State', + ] return ( { /> } /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page From de035243aa1090ac818cc2d02e651007a90723e1 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 27 May 2026 20:59:42 +0200 Subject: [PATCH 113/164] fixes #6065 --- src/pages/tenant/manage/user-defaults.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 8eb4f2592f61..3a5c3a150899 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -124,7 +124,7 @@ const Page = () => { labelField: (option) => `${option.License || option.skuPartNumber} (${option.AvailableUnits || 0} available)`, valueField: 'skuId', - queryKey: 'ListLicenses', + queryKey: `ListLicenses-${userSettings.currentTenant}`, }, multiple: true, creatable: false, @@ -137,7 +137,7 @@ const Page = () => { url: '/api/ListGroups', labelField: 'displayName', valueField: 'id', - queryKey: 'ListGroups', + queryKey: `ListGroups-${userSettings.currentTenant}`, addedField: { groupType: 'calculatedGroupType', }, From d0f58cbebd48dbdeaddfcf3af0f68d7b4320bec8 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 27 May 2026 22:43:01 +0200 Subject: [PATCH 114/164] feat(mailboxes): show mailbox and archive size columns Add archive-enabled filtering and cached mailbox/archive size columns to the mailbox table. Format archive size as GB using the shared CIPP formatting utility while keeping count and quota fields available through the column picker. Fixes #6061 --- src/pages/email/administration/mailboxes/index.js | 8 ++++++++ src/utils/get-cipp-formatting.js | 8 ++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/pages/email/administration/mailboxes/index.js b/src/pages/email/administration/mailboxes/index.js index 01bc84afae65..4f689548fabf 100644 --- a/src/pages/email/administration/mailboxes/index.js +++ b/src/pages/email/administration/mailboxes/index.js @@ -44,6 +44,11 @@ const Page = () => { value: [{ id: 'recipientTypeDetails', value: 'EquipmentMailbox' }], type: 'column', }, + { + filterName: 'View Archive-Enabled Mailboxes', + value: [{ id: 'ArchiveEnabled', value: true }], + type: 'column', + }, ] // Simplified columns for the table @@ -54,6 +59,9 @@ const Page = () => { 'UPN', 'primarySmtpAddress', 'AdditionalEmailAddresses', + ...(reportDB.useReportDB ? ['storageUsedInBytes'] : []), + 'ArchiveEnabled', + ...(reportDB.useReportDB ? ['ArchiveSize'] : []), ...reportDB.cacheColumns.filter((c) => c !== 'Tenant'), ] diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 5580ab4b5de0..fc313c53cae7 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -152,7 +152,11 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr ) } - if (cellName === 'prohibitSendReceiveQuotaInBytes' || cellName === 'storageUsedInBytes') { + if ( + cellName === 'prohibitSendReceiveQuotaInBytes' || + cellName === 'storageUsedInBytes' || + cellName === 'ArchiveSize' + ) { //convert bytes to GB const bytes = data if (bytes === null || bytes === undefined) { @@ -1022,7 +1026,7 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr } // Internal CIPP navigation links - if ((cellName === 'cippLink') && typeof data === 'string') { + if (cellName === 'cippLink' && typeof data === 'string') { return isText ? ( data ) : ( From 072416dd95fb955625d466022ebb4a05847e76e3 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 28 May 2026 00:54:29 +0200 Subject: [PATCH 115/164] new autopatch standard --- src/data/standards.json | 238 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index dbe64432f9d5..464b433e4cb6 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -7235,6 +7235,244 @@ "powershellEquivalent": "Graph API - /deviceAppManagement/mobileApps", "recommendedBy": [] }, + { + "name": "standards.AutopatchGroup", + "cat": "Intune Standards", + "tag": [], + "beta": true, + "deprecated": true, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Deploys a Windows Autopatch group with configurable deployment ring settings for quality updates, feature updates, Edge, and Office.", + "docsDescription": "Creates or updates a Windows Autopatch deployment group with Test and Last deployment rings. Configures quality update deferrals, feature update targeting, Edge and Office update channels per ring. Uses the Autopatch API proxy to manage the group configuration.", + "executiveText": "Configures Windows Autopatch deployment groups to manage update delivery across devices. Autopatch automates Windows quality updates, feature updates, Edge, and Office updates using deployment rings with configurable deferrals and deadlines.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.AutopatchGroup.GroupName", + "label": "Group Name", + "required": true, + "defaultValue": "Autopatch default group" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TargetOSVersion", + "label": "Target OS Version", + "required": true, + "options": [ + { "label": "Windows 11, version 24H2", "value": "Windows 11, version 24H2" }, + { "label": "Windows 11, version 25H2", "value": "Windows 11, version 25H2" } + ], + "defaultValue": "Windows 11, version 25H2" + }, + { + "type": "switch", + "name": "standards.AutopatchGroup.EnableDriverUpdate", + "label": "Enable Driver Updates", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.AutopatchGroup.InstallWin10OnWin11Ineligible", + "label": "Install latest Windows 10 on Windows 11 ineligible devices", + "defaultValue": false + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityDeferral", + "label": "Test Ring - Quality Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityDeadline", + "label": "Test Ring - Quality Update Deadline (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityGracePeriod", + "label": "Test Ring - Quality Update Grace Period (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 7, "message": "Maximum value is 7" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestFeatureDeferral", + "label": "Test Ring - Feature Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestFeatureDeadline", + "label": "Test Ring - Feature Update Deadline (days)", + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TestEdgeChannel", + "label": "Test Ring - Edge Update Channel", + "options": [ + { "label": "Stable", "value": "Stable" }, + { "label": "Beta", "value": "Beta" }, + { "label": "Dev", "value": "Dev" } + ], + "defaultValue": "Beta" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TestOfficeChannel", + "label": "Test Ring - Office Update Channel", + "options": [ + { "label": "Current", "value": "Current" }, + { "label": "Monthly Enterprise", "value": "MonthlyEnterprise" }, + { "label": "Semi-Annual Enterprise", "value": "SemiAnnual" } + ], + "defaultValue": "MonthlyEnterprise" + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestDnfDeferral", + "label": "Test Ring - Driver & Firmware Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityDeferral", + "label": "Last Ring - Quality Update Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityDeadline", + "label": "Last Ring - Quality Update Deadline (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityGracePeriod", + "label": "Last Ring - Quality Update Grace Period (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 7, "message": "Maximum value is 7" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastFeatureDeferral", + "label": "Last Ring - Feature Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastFeatureDeadline", + "label": "Last Ring - Feature Update Deadline (days)", + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.LastEdgeChannel", + "label": "Last Ring - Edge Update Channel", + "options": [ + { "label": "Stable", "value": "Stable" }, + { "label": "Beta", "value": "Beta" }, + { "label": "Dev", "value": "Dev" } + ], + "defaultValue": "Stable" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.LastOfficeChannel", + "label": "Last Ring - Office Update Channel", + "options": [ + { "label": "Current", "value": "Current" }, + { "label": "Monthly Enterprise", "value": "MonthlyEnterprise" }, + { "label": "Semi-Annual Enterprise", "value": "SemiAnnual" } + ], + "defaultValue": "MonthlyEnterprise" + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastOfficeDeferral", + "label": "Last Ring - Office Update Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastOfficeDeadline", + "label": "Last Ring - Office Update Deadline (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastDnfDeferral", + "label": "Last Ring - Driver & Firmware Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + } + ], + "label": "Deploy Windows Autopatch Group", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-27", + "powershellEquivalent": "Autopatch API - POST /api/autoPatch", + "recommendedBy": [] + }, { "name": "standards.FIDO2PasskeyProfiles", "cat": "Entra (AAD) Standards", From b8ece5c448ee814f5400dacaaee9a3590c911cde Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 28 May 2026 13:56:39 +0800 Subject: [PATCH 116/164] Update worker-health.js --- src/pages/cipp/advanced/worker-health.js | 77 +++++++++++++++++++----- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/src/pages/cipp/advanced/worker-health.js b/src/pages/cipp/advanced/worker-health.js index ccc19a335570..721f6c578799 100644 --- a/src/pages/cipp/advanced/worker-health.js +++ b/src/pages/cipp/advanced/worker-health.js @@ -33,7 +33,6 @@ import { Cancel, Delete, LowPriority, - DeleteSweep, Timeline, RocketLaunch, Pause, @@ -306,6 +305,7 @@ const CompactStatsRow = ({ snapshot }) => { const bg = snapshot.BgPool || {}; const jobs = snapshot.Jobs || {}; const limiter = snapshot.Limiter || {}; + const mem = snapshot.Memory || {}; const sections = [ { @@ -351,6 +351,18 @@ const CompactStatsRow = ({ snapshot }) => { ...(limiter.IsHttpThrottled ? [{ k: "Status", v: "Throttled", w: true }] : []), ], }, + { + label: "Memory", + color: "secondary", + stats: [ + { k: "Heap", v: `${mem.HeapMB ?? 0}MB` }, + { k: "RSS", v: `${mem.RssMB ?? 0}MB`, w: mem.UsagePct > 85 }, + { k: "Committed", v: `${mem.CommittedMB ?? 0}MB` }, + { k: "Limit", v: `${mem.ContainerLimitMB ?? 0}MB` }, + { k: "Usage", v: `${mem.UsagePct ?? 0}%`, w: mem.UsagePct > 85 }, + { k: "GC", v: `${mem.GC0 ?? 0}/${mem.GC1 ?? 0}/${mem.GC2 ?? 0}` }, + ], + }, ]; return ( @@ -427,6 +439,7 @@ const Page = () => { const [historyRange, setHistoryRange] = useState(60); const [paused, setPaused] = useState(false); const [importedData, setImportedData] = useState(null); + const [jobLimit, setJobLimit] = useState(2000); const isImported = importedData !== null; const effectivePaused = paused || isImported; @@ -573,10 +586,16 @@ const Page = () => { : `${limiter.Active ?? 0} / ${limiter.CurrentMax ?? 0} active`, color: limiter.IsHttpThrottled ? "error" : "primary", }, + { + icon: , + name: "Memory", + data: `${snapshot.Memory?.RssMB ?? 0}MB / ${snapshot.Memory?.ContainerLimitMB ?? 0}MB (${snapshot.Memory?.UsagePct ?? 0}%)`, + color: (snapshot.Memory?.UsagePct ?? 0) > 85 ? "error" : (snapshot.Memory?.UsagePct ?? 0) > 70 ? "warning" : "primary", + }, ]; }, [snapshot]); - const jobSimpleColumns = ["Name", "RunName", "Priority", "Status", "WaitSeconds", "DurationSeconds"]; + const jobSimpleColumns = ["Name", "RunName", "Priority", "Status", "QueuedUtc", "WaitSeconds", "DurationSeconds"]; const jobActions = useMemo( () => [ @@ -761,30 +780,29 @@ const Page = () => { ) : ( val !== null && setJobLimit(val)} size="small" - startIcon={} - color="warning" - onClick={() => - jobAction.mutate({ - url: "/api/ListWorkerHealth", - data: { Action: "PurgeCompleted" }, - }) - } > - Purge Completed - + {[500, 2000, 5000, 10000].map((n) => ( + + {n >= 1000 ? `${n / 1000}k` : n} + + ))} + } /> )} @@ -878,6 +896,33 @@ const Page = () => { + + } + > + {(data, t) => ( + + + + + + + + + + + )} + + Date: Thu, 28 May 2026 15:21:20 +0800 Subject: [PATCH 117/164] Update worker-health.js --- src/pages/cipp/advanced/worker-health.js | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/pages/cipp/advanced/worker-health.js b/src/pages/cipp/advanced/worker-health.js index 721f6c578799..a058b168882e 100644 --- a/src/pages/cipp/advanced/worker-health.js +++ b/src/pages/cipp/advanced/worker-health.js @@ -360,6 +360,7 @@ const CompactStatsRow = ({ snapshot }) => { { k: "Committed", v: `${mem.CommittedMB ?? 0}MB` }, { k: "Limit", v: `${mem.ContainerLimitMB ?? 0}MB` }, { k: "Usage", v: `${mem.UsagePct ?? 0}%`, w: mem.UsagePct > 85 }, + { k: "CPU", v: `${mem.CpuPct ?? 0}%`, w: mem.CpuPct > 80 }, { k: "GC", v: `${mem.GC0 ?? 0}/${mem.GC1 ?? 0}/${mem.GC2 ?? 0}` }, ], }, @@ -592,6 +593,12 @@ const Page = () => { data: `${snapshot.Memory?.RssMB ?? 0}MB / ${snapshot.Memory?.ContainerLimitMB ?? 0}MB (${snapshot.Memory?.UsagePct ?? 0}%)`, color: (snapshot.Memory?.UsagePct ?? 0) > 85 ? "error" : (snapshot.Memory?.UsagePct ?? 0) > 70 ? "warning" : "primary", }, + { + icon: , + name: "CPU", + data: `${snapshot.Memory?.CpuPct ?? 0}%`, + color: (snapshot.Memory?.CpuPct ?? 0) > 80 ? "error" : (snapshot.Memory?.CpuPct ?? 0) > 50 ? "warning" : "primary", + }, ]; }, [snapshot]); @@ -923,6 +930,34 @@ const Page = () => { )} + + } + > + {(data, t) => ( + + + + + + + + + )} + + + + + Date: Thu, 28 May 2026 20:51:17 +0800 Subject: [PATCH 118/164] Update worker-health.js --- src/pages/cipp/advanced/worker-health.js | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/pages/cipp/advanced/worker-health.js b/src/pages/cipp/advanced/worker-health.js index a058b168882e..19d68519852f 100644 --- a/src/pages/cipp/advanced/worker-health.js +++ b/src/pages/cipp/advanced/worker-health.js @@ -359,9 +359,11 @@ const CompactStatsRow = ({ snapshot }) => { { k: "RSS", v: `${mem.RssMB ?? 0}MB`, w: mem.UsagePct > 85 }, { k: "Committed", v: `${mem.CommittedMB ?? 0}MB` }, { k: "Limit", v: `${mem.ContainerLimitMB ?? 0}MB` }, + { k: "GC Limit", v: `${mem.GCHeapLimitMB ?? 0}MB` }, { k: "Usage", v: `${mem.UsagePct ?? 0}%`, w: mem.UsagePct > 85 }, { k: "CPU", v: `${mem.CpuPct ?? 0}%`, w: mem.CpuPct > 80 }, { k: "GC", v: `${mem.GC0 ?? 0}/${mem.GC1 ?? 0}/${mem.GC2 ?? 0}` }, + ...(mem.TestDataCacheCount != null ? [{ k: "Cache", v: `${mem.TestDataCacheCount} entries` }] : []), ], }, ]; @@ -469,6 +471,13 @@ const Page = () => { relatedQueryKeys: ["WorkerHealthJobs", "WorkerHealth"], }); + const cacheDiagQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "CacheDiag" }, + queryKey: "WorkerCacheDiag", + refetchInterval: effectivePaused ? false : 30000, + }); + // Resolve data: imported overrides live const snapshot = isImported ? importedData.snapshot : healthQuery.data?.Results; const startupInfo = isImported ? importedData.startup : startupQuery.data?.Results; @@ -1015,6 +1024,55 @@ const Page = () => { + {/* ── TestData Cache Diagnostics ── */} + {(() => { + const diag = cacheDiagQuery.data?.Results; + if (!diag) return null; + const types = diag.TypeBreakdown ?? []; + return ( + + 5000 ? "error" : diag.TotalEntries > 1000 ? "warning" : "success"} + size="small" + /> + } + /> + {types.length > 0 && ( + + + + + + Data Type + Tenants + Items + Est. MB + + + + {types.map((t) => ( + + {t.Type} + {t.EntryCount} + {t.TotalItems?.toLocaleString()} + {t.TotalMB} + + ))} + +
+
+
+ )} +
+ ); + })()} + {/* ── Startup Timing (bottom) ── */}
From 7e44aff88a1ff1fbb29e8d3a9fb9aa5c70c1b504 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 28 May 2026 15:21:13 +0200 Subject: [PATCH 119/164] new auth methods single standard --- src/data/standards.json | 347 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index 464b433e4cb6..14f0fbcef627 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -465,6 +465,353 @@ "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", "recommendedBy": [] }, + { + "name": "standards.AuthenticationMethods", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Enable or disable each method and optionally target specific groups.", + "docsDescription": "Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP.", + "executiveText": "Provides centralized control over all tenant authentication methods from a single standard. Administrators can enable phishing-resistant methods like FIDO2 and Microsoft Authenticator while disabling less secure options like SMS and Voice. Each method supports group-level targeting using wildcard group names, allowing staged rollouts and granular control.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "label": "Microsoft Authenticator", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorSoftwareOath", + "label": "Enable Software OTP in Authenticator", + "defaultValue": false, + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Number Matching", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorNumberMatching", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Show Application Name in Push Notifications", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayAppInfo", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Show Geographic Location in Push Notifications", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayLocation", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Companion App (Authenticator Lite)", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorCompanionApp", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.FIDO2Enabled", + "label": "FIDO2 Security Keys", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.FIDO2Group", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.FIDO2Enabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.TAPEnabled", + "label": "Temporary Access Pass", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.TAPGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "TAP Usage Mode", + "name": "standards.AuthenticationMethods.TAPUsableOnce", + "options": [ + { "label": "Only Once", "value": "true" }, + { "label": "Multiple Logons", "value": "false" } + ], + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPDefaultLifetime", + "label": "TAP Default Lifetime (minutes)", + "defaultValue": 60, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPMinLifetime", + "label": "TAP Minimum Lifetime (minutes)", + "defaultValue": 60, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPMaxLifetime", + "label": "TAP Maximum Lifetime (minutes)", + "defaultValue": 480, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPDefaultLength", + "label": "TAP Length (characters)", + "defaultValue": 8, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.SoftwareOathEnabled", + "label": "Third-Party Software OATH Tokens", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.SoftwareOathGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.SoftwareOathEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.HardwareOathEnabled", + "label": "Hardware OATH Tokens", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.HardwareOathGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.HardwareOathEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.SMSEnabled", + "label": "SMS", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.SMSGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.SMSEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.VoiceEnabled", + "label": "Voice Call", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.VoiceGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.VoiceEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.EmailEnabled", + "label": "Email OTP", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.EmailGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.EmailEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.x509CertificateEnabled", + "label": "Certificate-Based Authentication", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.x509CertificateGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.x509CertificateEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.QRCodePinEnabled", + "label": "QR Code Pin", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.QRCodePinGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.QRCodeLifetimeInDays", + "label": "QR Code Lifetime (days, 1-395)", + "defaultValue": 365, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.QRCodePinLength", + "label": "QR Code PIN Length (8-20)", + "defaultValue": 8, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + } + ], + "label": "Configure Authentication Methods", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-05-28", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", + "recommendedBy": ["CIPP"] + }, { "name": "standards.AdminSSPR", "cat": "Entra (AAD) Standards", From 605ecd8272e8e6c5d6eaba7692fa0d3ddb22c745 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 28 May 2026 21:13:05 +0200 Subject: [PATCH 120/164] fix: move ADE pages --- src/layouts/config.js | 2 +- .../enrollment-profiles/android-enterprise.js | 0 .../{MEM => autopilot}/enrollment-profiles/apple-ade.js | 0 .../endpoint/{MEM => autopilot}/enrollment-profiles/index.js | 0 .../{MEM => autopilot}/enrollment-profiles/tabOptions.json | 0 .../{MEM => autopilot}/enrollment-profiles/windows-autopilot.js | 0 6 files changed, 1 insertion(+), 1 deletion(-) rename src/pages/endpoint/{MEM => autopilot}/enrollment-profiles/android-enterprise.js (100%) rename src/pages/endpoint/{MEM => autopilot}/enrollment-profiles/apple-ade.js (100%) rename src/pages/endpoint/{MEM => autopilot}/enrollment-profiles/index.js (100%) rename src/pages/endpoint/{MEM => autopilot}/enrollment-profiles/tabOptions.json (100%) rename src/pages/endpoint/{MEM => autopilot}/enrollment-profiles/windows-autopilot.js (100%) diff --git a/src/layouts/config.js b/src/layouts/config.js index d5f25685c697..7a41ee8ca57f 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -497,7 +497,7 @@ export const nativeMenuItems = [ }, { title: 'Enrollment Profiles', - path: '/endpoint/MEM/enrollment-profiles', + path: '/endpoint/autopilot/enrollment-profiles', permissions: ['Endpoint.Autopilot.*'], }, { diff --git a/src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js b/src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js similarity index 100% rename from src/pages/endpoint/MEM/enrollment-profiles/android-enterprise.js rename to src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js diff --git a/src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js b/src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js similarity index 100% rename from src/pages/endpoint/MEM/enrollment-profiles/apple-ade.js rename to src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js diff --git a/src/pages/endpoint/MEM/enrollment-profiles/index.js b/src/pages/endpoint/autopilot/enrollment-profiles/index.js similarity index 100% rename from src/pages/endpoint/MEM/enrollment-profiles/index.js rename to src/pages/endpoint/autopilot/enrollment-profiles/index.js diff --git a/src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json b/src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json similarity index 100% rename from src/pages/endpoint/MEM/enrollment-profiles/tabOptions.json rename to src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json diff --git a/src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js b/src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js similarity index 100% rename from src/pages/endpoint/MEM/enrollment-profiles/windows-autopilot.js rename to src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js From 063550f430ee43d716ae24a1e7834d10e2aa3014 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 28 May 2026 21:20:53 +0200 Subject: [PATCH 121/164] chore: update tab paths and imports --- .../autopilot/enrollment-profiles/android-enterprise.js | 2 +- .../endpoint/autopilot/enrollment-profiles/apple-ade.js | 2 +- src/pages/endpoint/autopilot/enrollment-profiles/index.js | 2 +- .../endpoint/autopilot/enrollment-profiles/tabOptions.json | 6 +++--- .../autopilot/enrollment-profiles/windows-autopilot.js | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js b/src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js index 88f86700374a..1718233d4b09 100644 --- a/src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js +++ b/src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js @@ -1,5 +1,5 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' -import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' import { AndroidEnterpriseEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js b/src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js index 2225de59f0a8..f03c313db2fd 100644 --- a/src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js +++ b/src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js @@ -1,5 +1,5 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' -import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' import { AppleADEEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/index.js b/src/pages/endpoint/autopilot/enrollment-profiles/index.js index a2cf307e80d2..00874da7788a 100644 --- a/src/pages/endpoint/autopilot/enrollment-profiles/index.js +++ b/src/pages/endpoint/autopilot/enrollment-profiles/index.js @@ -1,5 +1,5 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' -import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json b/src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json index 66a042cbd2ad..83abfededa9e 100644 --- a/src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json +++ b/src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json @@ -1,17 +1,17 @@ [ { "label": "Windows Autopilot", - "path": "/endpoint/MEM/enrollment-profiles", + "path": "/endpoint/autopilot/enrollment-profiles", "icon": "Window" }, { "label": "Apple ADE", - "path": "/endpoint/MEM/enrollment-profiles/apple-ade", + "path": "/endpoint/autopilot/enrollment-profiles/apple-ade", "icon": "Apple" }, { "label": "Android Enterprise", - "path": "/endpoint/MEM/enrollment-profiles/android-enterprise", + "path": "/endpoint/autopilot/enrollment-profiles/android-enterprise", "icon": "Android" } ] diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js b/src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js index a2cf307e80d2..00874da7788a 100644 --- a/src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js +++ b/src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js @@ -1,5 +1,5 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' -import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' import tabOptions from './tabOptions.json' From d0405ff45e5ad572438e007734a93f2b5a3b69f0 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 28 May 2026 22:45:31 +0200 Subject: [PATCH 122/164] feat: Add icons to the tabs and remove dead tab Add the icons into the registry too if they are not already there. --- .../cipp/advanced/super-admin/tabOptions.json | 27 +- src/pages/cipp/custom-data/tabOptions.json | 9 +- src/pages/cipp/settings/tabOptions.json | 29 +- src/pages/dashboardv2/tabOptions.json | 15 +- .../exchange-retention/tabOptions.json | 6 +- .../MEM/devices/device/tabOptions.json | 3 +- .../groups/group/tabOptions.json | 3 +- .../administration/users/user/devices.jsx | 380 ------------------ .../administration/users/user/tabOptions.json | 15 +- .../alert-configuration/tabOptions.json | 6 +- .../app-registration/tabOptions.json | 6 +- .../enterprise-app/tabOptions.json | 6 +- .../applications/tabOptions.json | 14 +- .../administration/audit-logs/tabOptions.json | 11 +- .../securescore/tabOptions.json | 6 +- .../administration/tenants/tabOptions.json | 9 +- .../relationship/tabOptions.json | 6 +- .../tenant/gdap-management/tabOptions.json | 21 +- src/pages/tenant/manage/tabOptions.json | 21 +- src/pages/tenant/standards/tabOptions.json | 8 +- .../tools/report-builder/tabOptions.json | 6 +- src/utils/icon-registry.js | 10 + 22 files changed, 160 insertions(+), 457 deletions(-) delete mode 100644 src/pages/identity/administration/users/user/devices.jsx diff --git a/src/pages/cipp/advanced/super-admin/tabOptions.json b/src/pages/cipp/advanced/super-admin/tabOptions.json index fbccb6b73c55..e0d9e9bad597 100644 --- a/src/pages/cipp/advanced/super-admin/tabOptions.json +++ b/src/pages/cipp/advanced/super-admin/tabOptions.json @@ -1,38 +1,47 @@ [ { "label": "Tenant Mode", - "path": "/cipp/advanced/super-admin/tenant-mode" + "path": "/cipp/advanced/super-admin/tenant-mode", + "icon": "Domain" }, { "label": "Function Offloading", - "path": "/cipp/advanced/super-admin/function-offloading" + "path": "/cipp/advanced/super-admin/function-offloading", + "icon": "Cloud" }, { "label": "Time Settings", - "path": "/cipp/advanced/super-admin/time-settings" + "path": "/cipp/advanced/super-admin/time-settings", + "icon": "AccessTime" }, { "label": "CIPP Roles", - "path": "/cipp/advanced/super-admin/cipp-roles" + "path": "/cipp/advanced/super-admin/cipp-roles", + "icon": "AdminPanelSettings" }, { "label": "SAM App Roles", - "path": "/cipp/advanced/super-admin/sam-app-roles" + "path": "/cipp/advanced/super-admin/sam-app-roles", + "icon": "Key" }, { "label": "SAM App Permissions", - "path": "/cipp/advanced/super-admin/sam-app-permissions" + "path": "/cipp/advanced/super-admin/sam-app-permissions", + "icon": "Lock" }, { "label": "CIPP Users", - "path": "/cipp/advanced/super-admin/cipp-users" + "path": "/cipp/advanced/super-admin/cipp-users", + "icon": "Group" }, { "label": "SSO", - "path": "/cipp/advanced/super-admin/sso" + "path": "/cipp/advanced/super-admin/sso", + "icon": "Shield" }, { "label": "Container Management", - "path": "/cipp/advanced/super-admin/container" + "path": "/cipp/advanced/super-admin/container", + "icon": "Storage" } ] diff --git a/src/pages/cipp/custom-data/tabOptions.json b/src/pages/cipp/custom-data/tabOptions.json index de0055483d82..bc87adf4dc60 100644 --- a/src/pages/cipp/custom-data/tabOptions.json +++ b/src/pages/cipp/custom-data/tabOptions.json @@ -1,14 +1,17 @@ [ { "label": "Directory Extensions", - "path": "/cipp/custom-data/directory-extensions" + "path": "/cipp/custom-data/directory-extensions", + "icon": "Dns" }, { "label": "Schema Extensions", - "path": "/cipp/custom-data/schema-extensions" + "path": "/cipp/custom-data/schema-extensions", + "icon": "Description" }, { "label": "Mappings", - "path": "/cipp/custom-data/mappings" + "path": "/cipp/custom-data/mappings", + "icon": "Share" } ] diff --git a/src/pages/cipp/settings/tabOptions.json b/src/pages/cipp/settings/tabOptions.json index 143f94ffbf49..da85db4cab73 100644 --- a/src/pages/cipp/settings/tabOptions.json +++ b/src/pages/cipp/settings/tabOptions.json @@ -1,38 +1,47 @@ [ { "label": "General", - "path": "/cipp/settings" + "path": "/cipp/settings", + "icon": "Settings" }, { "label": "Permissions", - "path": "/cipp/settings/permissions" + "path": "/cipp/settings/permissions", + "icon": "Key" }, { "label": "Tenants", - "path": "/cipp/settings/tenants" + "path": "/cipp/settings/tenants", + "icon": "Domain" }, { "label": "Backend", - "path": "/cipp/settings/backend" + "path": "/cipp/settings/backend", + "icon": "Cloud" }, { "label": "Notifications", - "path": "/cipp/settings/notifications" + "path": "/cipp/settings/notifications", + "icon": "Notifications" }, { "label": "Automated Onboarding", - "path": "/cipp/settings/partner-webhooks" + "path": "/cipp/settings/partner-webhooks", + "icon": "AutoMode" }, { "label": "Licenses", - "path": "/cipp/settings/licenses" + "path": "/cipp/settings/licenses", + "icon": "Assignment" }, { "label": "Features", - "path": "/cipp/settings/features" + "path": "/cipp/settings/features", + "icon": "Apps" }, { "label": "SIEM", - "path": "/cipp/settings/siem" + "path": "/cipp/settings/siem", + "icon": "Security" } -] \ No newline at end of file +] diff --git a/src/pages/dashboardv2/tabOptions.json b/src/pages/dashboardv2/tabOptions.json index 752f38c9d419..6014478ba77b 100644 --- a/src/pages/dashboardv2/tabOptions.json +++ b/src/pages/dashboardv2/tabOptions.json @@ -1,22 +1,27 @@ [ { "label": "Overview", - "path": "/dashboardv2" + "path": "/dashboardv2", + "icon": "Dashboard" }, { "label": "Identity", - "path": "/dashboardv2/identity" + "path": "/dashboardv2/identity", + "icon": "Person" }, { "label": "Devices", - "path": "/dashboardv2/devices" + "path": "/dashboardv2/devices", + "icon": "Devices" }, { "label": "Custom", - "path": "/dashboardv2/custom" + "path": "/dashboardv2/custom", + "icon": "Apps" }, { "label": "Previous Dashboard Experience", - "path": "/dashboardv1" + "path": "/dashboardv1", + "icon": "History" } ] diff --git a/src/pages/email/administration/exchange-retention/tabOptions.json b/src/pages/email/administration/exchange-retention/tabOptions.json index e6e203b5c611..f895eaae68c5 100644 --- a/src/pages/email/administration/exchange-retention/tabOptions.json +++ b/src/pages/email/administration/exchange-retention/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Policies", - "path": "/email/administration/exchange-retention/policies" + "path": "/email/administration/exchange-retention/policies", + "icon": "Policy" }, { "label": "Tags", - "path": "/email/administration/exchange-retention/tags" + "path": "/email/administration/exchange-retention/tags", + "icon": "Label" } ] diff --git a/src/pages/endpoint/MEM/devices/device/tabOptions.json b/src/pages/endpoint/MEM/devices/device/tabOptions.json index e5e134f5566a..558cbe2391bb 100644 --- a/src/pages/endpoint/MEM/devices/device/tabOptions.json +++ b/src/pages/endpoint/MEM/devices/device/tabOptions.json @@ -1,6 +1,7 @@ [ { "label": "View Device", - "path": "/endpoint/MEM/devices/device" + "path": "/endpoint/MEM/devices/device", + "icon": "Computer" } ] diff --git a/src/pages/identity/administration/groups/group/tabOptions.json b/src/pages/identity/administration/groups/group/tabOptions.json index f092f4cb37d6..18fa62ad879f 100644 --- a/src/pages/identity/administration/groups/group/tabOptions.json +++ b/src/pages/identity/administration/groups/group/tabOptions.json @@ -1,6 +1,7 @@ [ { "label": "View Group", - "path": "/identity/administration/groups/group" + "path": "/identity/administration/groups/group", + "icon": "Group" } ] diff --git a/src/pages/identity/administration/users/user/devices.jsx b/src/pages/identity/administration/users/user/devices.jsx deleted file mode 100644 index 9fcb9086bdbc..000000000000 --- a/src/pages/identity/administration/users/user/devices.jsx +++ /dev/null @@ -1,380 +0,0 @@ -import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; -import { useSettings } from "../../../../../hooks/use-settings"; -import { useRouter } from "next/router"; -import { ApiGetCall } from "../../../../../api/ApiCall"; -import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton"; -import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon"; -import { Check, Mail, Fingerprint } from "@mui/icons-material"; -import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; -import tabOptions from "./tabOptions"; -import ReactTimeAgo from "react-time-ago"; -import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard"; -import { Box, Stack } from "@mui/system"; -import { Grid } from "@mui/system"; -import { CippUserInfoCard } from "../../../../../components/CippCards/CippUserInfoCard"; -import { Typography } from "@mui/material"; -import { CippBannerListCard } from "../../../../../components/CippCards/CippBannerListCard"; - -const Page = () => { - const userSettingsDefaults = useSettings(); - const router = useRouter(); - const { userId } = router.query; - - const userRequest = ApiGetCall({ - url: `/api/ListUsers?UserId=${userId}&tenantFilter=${userSettingsDefaults.currentTenant}`, - queryKey: `ListUsers-${userId}`, - }); - - const MFARequest = ApiGetCall({ - url: "/api/ListGraphRequest", - data: { - Endpoint: `/users/${userId}/authentication/methods`, - tenantFilter: userSettingsDefaults.currentTenant, - noPagination: true, - $top: 99, - }, - queryKey: `MFA-${userId}`, - }); - - const signInLogs = ApiGetCall({ - url: `/api/ListUserSigninLogs?UserId=${userId}&tenantFilter=${userSettingsDefaults.currentTenant}&top=1`, - queryKey: `ListSignIns-${userId}`, - }); - - // Set the title and subtitle for the layout - const title = userRequest.isSuccess ? userRequest.data?.[0]?.displayName : "Loading..."; - - const subtitle = userRequest.isSuccess - ? [ - { - icon: , - text: , - }, - { - icon: , - text: , - }, - { - icon: , - text: ( - <> - Created: {" "} - - ), - }, - ] - : []; - - const data = userRequest.data?.[0]; - - // Prepare the sign-in log item - let signInLogItem = null; - let conditionalAccessPoliciesItems = []; - let mfaDevicesItems = []; - - if (signInLogs.isSuccess && signInLogs.data && signInLogs.data.length > 0) { - const signInData = signInLogs.data[0]; - - signInLogItem = { - id: 1, - cardLabelBox: { - cardLabelBoxHeader: new Date(signInData.createdDateTime).getDate().toString(), - cardLabelBoxText: new Date(signInData.createdDateTime).toLocaleString("default", { - month: "short", - year: "numeric", - }), - }, - text: `Login ${signInData.status.errorCode === 0 ? "successful" : "failed"} from ${ - signInData.ipAddress || "unknown location" - }`, - subtext: `Logged into application ${signInData.resourceDisplayName || "Unknown Application"}`, - statusColor: signInData.status.errorCode === 0 ? "success.main" : "error.main", - statusText: signInData.status.errorCode === 0 ? "Success" : "Failed", - propertyItems: [ - { - label: "Client App Used", - value: signInData.clientAppUsed || "N/A", - }, - { - label: "Device Detail", - value: - signInData.deviceDetail?.operatingSystem || signInData.deviceDetail?.browser || "N/A", - }, - { - label: "MFA Type used", - value: signInData.mfaDetail?.authMethod || "N/A", - }, - { - label: "Additional Details", - value: signInData.status?.additionalDetails || "N/A", - }, - ], - }; - - // Prepare the conditional access policies items - if ( - signInData.appliedConditionalAccessPolicies && - Array.isArray(signInData.appliedConditionalAccessPolicies) - ) { - // Filter policies where result is "success" - const appliedPolicies = signInData.appliedConditionalAccessPolicies.filter( - (policy) => policy.result === "success" - ); - - if (appliedPolicies.length > 0) { - conditionalAccessPoliciesItems = appliedPolicies.map((policy) => ({ - id: policy.id, - cardLabelBox: { - cardLabelBoxHeader: new Date(signInData.createdDateTime).getDate().toString(), - cardLabelBoxText: new Date(signInData.createdDateTime).toLocaleString("default", { - month: "short", - year: "numeric", - }), - }, - text: policy.displayName, - subtext: `Policy applied: ${policy.result}`, - statusColor: "success.main", - statusText: "Applied", - propertyItems: [ - { - label: "Grant Controls", - value: - policy.enforcedGrantControls.length > 0 - ? policy.enforcedGrantControls.join(", ") - : "None", - }, - { - label: "Session Controls", - value: - policy.enforcedSessionControls.length > 0 - ? policy.enforcedSessionControls.join(", ") - : "None", - }, - { - label: "Conditions Satisfied", - value: policy.conditionsSatisfied || "N/A", - }, - ], - })); - } else { - // No applied policies - conditionalAccessPoliciesItems = [ - { - id: 1, - cardLabelBox: { - cardLabelBoxHeader: new Date(signInData.createdDateTime).getDate().toString(), - cardLabelBoxText: new Date(signInData.createdDateTime).toLocaleString("default", { - month: "short", - year: "numeric", - }), - }, - text: "No conditional access policies applied", - subtext: "No conditional access policies were applied during this sign-in.", - statusColor: "warning.main", - statusText: "No Policies Applied", - propertyItems: [], - }, - ]; - } - } else { - // appliedConditionalAccessPolicies is missing or not an array - conditionalAccessPoliciesItems = [ - { - id: 1, - cardLabelBox: { - cardLabelBoxHeader: new Date(signInData.createdDateTime).getDate().toString(), - cardLabelBoxText: new Date(signInData.createdDateTime).toLocaleString("default", { - month: "short", - year: "numeric", - }), - }, - text: "No conditional access policies available", - subtext: "No conditional access policies data is available for this sign-in.", - statusColor: "warning.main", - statusText: "No Data", - propertyItems: [], - }, - ]; - } - } else if (signInLogs.isError) { - signInLogItem = { - id: 1, - cardLabelBox: "!", - text: "Error loading sign-in logs. Do you have a P1 license?", - subtext: signInLogs.error.message, - statusColor: "error.main", - statusText: "Error", - propertyItems: [], - }; - - // Handle error for conditional access policies - conditionalAccessPoliciesItems = [ - { - id: 1, - cardLabelBox: "!", - text: "Error loading conditional access policies. Do you have a P1 license?", - subtext: signInLogs.error.message, - statusColor: "error.main", - statusText: "Error", - propertyItems: [], - }, - ]; - } else if (signInLogs.isSuccess && (!signInLogs.data || signInLogs.data.length === 0)) { - signInLogItem = { - id: 1, - cardLabelBox: "-", - text: "No sign-in logs available", - subtext: - "There are no sign-in logs for this user, or you do not have a P1 license to detect this data.", - statusColor: "warning.main", - statusText: "No Data", - propertyItems: [], - }; - - conditionalAccessPoliciesItems = [ - { - id: 1, - cardLabelBox: "-", - text: "No conditional access policies available", - subtext: - "There are no conditional access policies for this user, or you do not have a P1 license to detect this data.", - statusColor: "warning.main", - statusText: "No Data", - propertyItems: [], - }, - ]; - } - - // Prepare MFA devices items - if (MFARequest.isSuccess && MFARequest.data) { - const mfaResults = MFARequest.data.Results || []; - - // Exclude password authentication method - const mfaDevices = mfaResults.filter( - (method) => method["@odata.type"] !== "#microsoft.graph.passwordAuthenticationMethod" - ); - - if (mfaDevices.length > 0) { - mfaDevicesItems = mfaDevices.map((device, index) => ({ - id: index, - cardLabelBox: { - cardLabelBoxHeader: , - }, - text: device.displayName || "MFA Device", - subtext: device.deviceTag || device.clientAppName || "Unknown device", - statusColor: "success.main", - statusText: "Enabled", - propertyItems: [ - { - label: "Device Name", - value: device.displayName || "N/A", - }, - { - label: "App Version", - value: device.phoneAppVersion || "N/A", - }, - { - label: "Created Date", - value: device.createdDateTime - ? new Date(device.createdDateTime).toLocaleString() - : "N/A", - }, - { - label: "Authentication Method", - value: device["@odata.type"]?.split(".").pop() || "N/A", - }, - ], - })); - } else { - // No MFA devices other than password - mfaDevicesItems = [ - { - id: 1, - cardLabelBox: "-", - text: "No MFA devices available", - subtext: "The user does not have any MFA devices registered.", - statusColor: "warning.main", - statusText: "No Devices", - propertyItems: [], - }, - ]; - } - } else if (MFARequest.isError) { - // Error fetching MFA devices - mfaDevicesItems = [ - { - id: 1, - cardLabelBox: "!", - text: "Error loading MFA devices", - subtext: MFARequest.error.message, - statusColor: "error.main", - statusText: "Error", - propertyItems: [], - }, - ]; - } else if (MFARequest.isSuccess && (!MFARequest.data || !MFARequest.data.Results)) { - // No MFA devices data available - mfaDevicesItems = [ - { - id: 1, - cardLabelBox: "-", - text: "No MFA devices available", - subtext: "The user does not have any MFA devices registered.", - statusColor: "warning.main", - statusText: "No Devices", - propertyItems: [], - }, - ]; - } - - return ( - - {userRequest.isLoading && } - {userRequest.isSuccess && ( - - - - - - - - Latest Logon - - Applied Conditional Access Policies - 0 ? true : false} - /> - Multi-Factor Authentication Devices - 0 ? true : false} - /> - - - - - )} - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/pages/identity/administration/users/user/tabOptions.json b/src/pages/identity/administration/users/user/tabOptions.json index 5ef89715dfa8..61fd2ffbe806 100644 --- a/src/pages/identity/administration/users/user/tabOptions.json +++ b/src/pages/identity/administration/users/user/tabOptions.json @@ -1,22 +1,27 @@ [ { "label": "View User", - "path": "/identity/administration/users/user" + "path": "/identity/administration/users/user", + "icon": "Person" }, { "label": "Edit User", - "path": "/identity/administration/users/user/edit" + "path": "/identity/administration/users/user/edit", + "icon": "ManageAccounts" }, { "label": "Exchange Settings", - "path": "/identity/administration/users/user/exchange" + "path": "/identity/administration/users/user/exchange", + "icon": "Mail" }, { "label": "Compromise Remediation", - "path": "/identity/administration/users/user/bec" + "path": "/identity/administration/users/user/bec", + "icon": "Shield" }, { "label": "Conditional Access", - "path": "/identity/administration/users/user/conditional-access" + "path": "/identity/administration/users/user/conditional-access", + "icon": "Lock" } ] diff --git a/src/pages/tenant/administration/alert-configuration/tabOptions.json b/src/pages/tenant/administration/alert-configuration/tabOptions.json index 0aef9ef1ba5f..480e89d7cdfe 100644 --- a/src/pages/tenant/administration/alert-configuration/tabOptions.json +++ b/src/pages/tenant/administration/alert-configuration/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Alert Configuration", - "path": "/tenant/administration/alert-configuration" + "path": "/tenant/administration/alert-configuration", + "icon": "Notifications" }, { "label": "Snoozed Alerts", - "path": "/tenant/administration/alert-configuration/snoozed-alerts" + "path": "/tenant/administration/alert-configuration/snoozed-alerts", + "icon": "ShieldMoon" } ] diff --git a/src/pages/tenant/administration/applications/app-registration/tabOptions.json b/src/pages/tenant/administration/applications/app-registration/tabOptions.json index bcc5b9cbaa61..458efb66de5f 100644 --- a/src/pages/tenant/administration/applications/app-registration/tabOptions.json +++ b/src/pages/tenant/administration/applications/app-registration/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "View App Registration", - "path": "/tenant/administration/applications/app-registration" + "path": "/tenant/administration/applications/app-registration", + "icon": "Apps" }, { "label": "Permissions", - "path": "/tenant/administration/applications/app-registration/permissions" + "path": "/tenant/administration/applications/app-registration/permissions", + "icon": "Key" } ] diff --git a/src/pages/tenant/administration/applications/enterprise-app/tabOptions.json b/src/pages/tenant/administration/applications/enterprise-app/tabOptions.json index c0d82bfd8dad..346d9639fca1 100644 --- a/src/pages/tenant/administration/applications/enterprise-app/tabOptions.json +++ b/src/pages/tenant/administration/applications/enterprise-app/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "View Enterprise App", - "path": "/tenant/administration/applications/enterprise-app" + "path": "/tenant/administration/applications/enterprise-app", + "icon": "Business" }, { "label": "Permissions", - "path": "/tenant/administration/applications/enterprise-app/permissions" + "path": "/tenant/administration/applications/enterprise-app/permissions", + "icon": "Key" } ] diff --git a/src/pages/tenant/administration/applications/tabOptions.json b/src/pages/tenant/administration/applications/tabOptions.json index 05bafb200179..2f138f75fd29 100644 --- a/src/pages/tenant/administration/applications/tabOptions.json +++ b/src/pages/tenant/administration/applications/tabOptions.json @@ -1,18 +1,22 @@ [ { "label": "Enterprise Apps", - "path": "/tenant/administration/applications/enterprise-apps" + "path": "/tenant/administration/applications/enterprise-apps", + "icon": "Business" }, { "label": "App Registrations", - "path": "/tenant/administration/applications/app-registrations" + "path": "/tenant/administration/applications/app-registrations", + "icon": "Apps" }, { "label": "Permission Sets", - "path": "/tenant/administration/applications/permission-sets" + "path": "/tenant/administration/applications/permission-sets", + "icon": "Key" }, { "label": "Application Templates", - "path": "/tenant/administration/applications/templates" + "path": "/tenant/administration/applications/templates", + "icon": "Description" } -] \ No newline at end of file +] diff --git a/src/pages/tenant/administration/audit-logs/tabOptions.json b/src/pages/tenant/administration/audit-logs/tabOptions.json index 8c90389d9c68..9c5bf289488d 100644 --- a/src/pages/tenant/administration/audit-logs/tabOptions.json +++ b/src/pages/tenant/administration/audit-logs/tabOptions.json @@ -1,14 +1,17 @@ [ { "label": "Saved Logs", - "path": "/tenant/administration/audit-logs" + "path": "/tenant/administration/audit-logs", + "icon": "Storage" }, { "label": "Log Searches", - "path": "/tenant/administration/audit-logs/searches" + "path": "/tenant/administration/audit-logs/searches", + "icon": "List" }, { "label": "Directory Audits", - "path": "/tenant/administration/audit-logs/directory-audits" + "path": "/tenant/administration/audit-logs/directory-audits", + "icon": "FactCheck" } -] \ No newline at end of file +] diff --git a/src/pages/tenant/administration/securescore/tabOptions.json b/src/pages/tenant/administration/securescore/tabOptions.json index 1f7e8795f144..b4a4460ad2ec 100644 --- a/src/pages/tenant/administration/securescore/tabOptions.json +++ b/src/pages/tenant/administration/securescore/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Tenant Overview", - "path": "/tenant/administration/securescore" + "path": "/tenant/administration/securescore", + "icon": "Dashboard" }, { "label": "Table Overview", - "path": "/tenant/administration/securescore/table" + "path": "/tenant/administration/securescore/table", + "icon": "BarChart" } ] diff --git a/src/pages/tenant/administration/tenants/tabOptions.json b/src/pages/tenant/administration/tenants/tabOptions.json index f36fc14e96d0..9bb186e4211e 100644 --- a/src/pages/tenant/administration/tenants/tabOptions.json +++ b/src/pages/tenant/administration/tenants/tabOptions.json @@ -1,14 +1,17 @@ [ { "label": "Tenants", - "path": "/tenant/administration/tenants" + "path": "/tenant/administration/tenants", + "icon": "Domain" }, { "label": "Groups", - "path": "/tenant/administration/tenants/groups" + "path": "/tenant/administration/tenants/groups", + "icon": "Groups" }, { "label": "Global Variables", - "path": "/tenant/administration/tenants/global-variables" + "path": "/tenant/administration/tenants/global-variables", + "icon": "Settings" } ] diff --git a/src/pages/tenant/gdap-management/relationships/relationship/tabOptions.json b/src/pages/tenant/gdap-management/relationships/relationship/tabOptions.json index 5977d35a1f06..2f22feb09d01 100644 --- a/src/pages/tenant/gdap-management/relationships/relationship/tabOptions.json +++ b/src/pages/tenant/gdap-management/relationships/relationship/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Overview", - "path": "/tenant/gdap-management/relationships/relationship" + "path": "/tenant/gdap-management/relationships/relationship", + "icon": "Dashboard" }, { "label": "Mappings", - "path": "/tenant/gdap-management/relationships/relationship/mappings" + "path": "/tenant/gdap-management/relationships/relationship/mappings", + "icon": "Share" } ] diff --git a/src/pages/tenant/gdap-management/tabOptions.json b/src/pages/tenant/gdap-management/tabOptions.json index 20815ed009d0..3b41dcd84366 100644 --- a/src/pages/tenant/gdap-management/tabOptions.json +++ b/src/pages/tenant/gdap-management/tabOptions.json @@ -1,30 +1,37 @@ [ { "label": "Overview", - "path": "/tenant/gdap-management" + "path": "/tenant/gdap-management", + "icon": "Dashboard" }, { "label": "Relationships", - "path": "/tenant/gdap-management/relationships" + "path": "/tenant/gdap-management/relationships", + "icon": "Share" }, { "label": "Role Mappings", - "path": "/tenant/gdap-management/roles" + "path": "/tenant/gdap-management/roles", + "icon": "AdminPanelSettings" }, { "label": "Role Templates", - "path": "/tenant/gdap-management/role-templates" + "path": "/tenant/gdap-management/role-templates", + "icon": "Description" }, { "label": "Invites", - "path": "/tenant/gdap-management/invites" + "path": "/tenant/gdap-management/invites", + "icon": "Mail" }, { "label": "Onboarding", - "path": "/tenant/gdap-management/onboarding" + "path": "/tenant/gdap-management/onboarding", + "icon": "CheckCircle" }, { "label": "Offboarding", - "path": "/tenant/gdap-management/offboarding" + "path": "/tenant/gdap-management/offboarding", + "icon": "Warning" } ] diff --git a/src/pages/tenant/manage/tabOptions.json b/src/pages/tenant/manage/tabOptions.json index adf60d405b09..9fc74d76d3ed 100644 --- a/src/pages/tenant/manage/tabOptions.json +++ b/src/pages/tenant/manage/tabOptions.json @@ -1,30 +1,37 @@ [ { "label": "Edit Tenant", - "path": "/tenant/manage/edit" + "path": "/tenant/manage/edit", + "icon": "Settings" }, { "label": "Manage Drift", - "path": "/tenant/manage/drift" + "path": "/tenant/manage/drift", + "icon": "Sync" }, { "label": "Configuration Backup", - "path": "/tenant/manage/configuration-backup" + "path": "/tenant/manage/configuration-backup", + "icon": "Storage" }, { "label": "Applied Standards Report", - "path": "/tenant/manage/applied-standards" + "path": "/tenant/manage/applied-standards", + "icon": "FactCheck" }, { "label": "Policies and Settings Deployed", - "path": "/tenant/manage/policies-deployed" + "path": "/tenant/manage/policies-deployed", + "icon": "Policy" }, { "label": "User Defaults", - "path": "/tenant/manage/user-defaults" + "path": "/tenant/manage/user-defaults", + "icon": "Person" }, { "label": "History", - "path": "/tenant/manage/history" + "path": "/tenant/manage/history", + "icon": "Timeline" } ] diff --git a/src/pages/tenant/standards/tabOptions.json b/src/pages/tenant/standards/tabOptions.json index 26c6c751dc1a..59c2cfa5df76 100644 --- a/src/pages/tenant/standards/tabOptions.json +++ b/src/pages/tenant/standards/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Standard & Drift Alignment", - "path": "/tenant/standards/alignment" + "path": "/tenant/standards/alignment", + "icon": "FactCheck" }, { "label": "Templates", - "path": "/tenant/standards/templates" + "path": "/tenant/standards/templates", + "icon": "Description" } -] \ No newline at end of file +] diff --git a/src/pages/tools/report-builder/tabOptions.json b/src/pages/tools/report-builder/tabOptions.json index 18f2988321a8..c1f80bcc4890 100644 --- a/src/pages/tools/report-builder/tabOptions.json +++ b/src/pages/tools/report-builder/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Generated Reports", - "path": "/tools/report-builder/generated" + "path": "/tools/report-builder/generated", + "icon": "BarChart" }, { "label": "Templates", - "path": "/tools/report-builder/templates" + "path": "/tools/report-builder/templates", + "icon": "Description" } ] diff --git a/src/utils/icon-registry.js b/src/utils/icon-registry.js index 2863958edbb4..5f288e36c87d 100644 --- a/src/utils/icon-registry.js +++ b/src/utils/icon-registry.js @@ -1,4 +1,9 @@ import { + AccessTime, + AutoMode, + History, + Label, + Webhook, AdminPanelSettings, Android, Apple, @@ -44,6 +49,7 @@ import { } from '@mui/icons-material' export const iconRegistry = { + AccessTime, AdminPanelSettings, Android, Apple, @@ -82,8 +88,12 @@ export const iconRegistry = { Shield, ShieldMoon, Storage, + AutoMode, + History, + Label, Sync, Timeline, + Webhook, Window, Warning, } From 707873e31e3b9fae7504dea81643f7bf673a1bef Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 28 May 2026 23:24:30 +0200 Subject: [PATCH 123/164] fix: Fix tab title showing as undefined --- .../email/administration/exchange-retention/policies/index.js | 1 + src/pages/email/administration/exchange-retention/tags/index.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/pages/email/administration/exchange-retention/policies/index.js b/src/pages/email/administration/exchange-retention/policies/index.js index 650e79e76ea6..a66011d452aa 100644 --- a/src/pages/email/administration/exchange-retention/policies/index.js +++ b/src/pages/email/administration/exchange-retention/policies/index.js @@ -64,6 +64,7 @@ const Page = () => { return ( { return ( Date: Fri, 29 May 2026 16:31:56 +0800 Subject: [PATCH 124/164] Expose missing standards and allow removal --- .../CippStandards/CippStandardAccordion.jsx | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index 5aed7f6950a8..931a5c2cc271 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -28,6 +28,7 @@ import { NotificationImportant, Assignment, Construction, + Warning, } from "@mui/icons-material"; import { Grid } from "@mui/system"; import CippFormComponent from "../CippComponents/CippFormComponent"; @@ -401,7 +402,23 @@ const CippStandardAccordion = ({ Object.keys(selectedStandards).forEach((standardName) => { const baseStandardName = standardName.split("[")[0]; const standard = providedStandards.find((s) => s.name === baseStandardName); - if (!standard) return; + + if (!standard) { + // Unknown/deprecated standard — surface it so the user can remove it + const unknownCategory = "Unknown Standards"; + if (!result[unknownCategory]) { + result[unknownCategory] = []; + } + result[unknownCategory].push({ + standardName, + standard: { + _unknown: true, + name: baseStandardName, + label: baseStandardName, + }, + }); + return; + } const standardInfo = standards.find((s) => s.name === baseStandardName); const category = standardInfo?.cat || "Other Standards"; @@ -613,6 +630,69 @@ const CippStandardAccordion = ({ {filteredGroupedStandards[category].map(({ standardName, standard }) => { + if (standard._unknown) { + const isExpanded = expanded === standardName; + const rawData = get(watchedValues, standardName); + return ( + + + + + + + + {standard.label} + + This standard no longer exists and should be removed. + + + + + + handleRemoveStandard(standardName)}> + + + + handleAccordionToggle(standardName)}> + + + + + + + + + Stored Configuration + + + {JSON.stringify(rawData, null, 2)} + + + + + ); + } + const isExpanded = expanded === standardName; const hasAddedComponents = standard.addedComponent && standard.addedComponent.length > 0; From c0bfd7df29808523b2ab94847fff8b7736681c43 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 29 May 2026 17:43:25 +0800 Subject: [PATCH 125/164] Update standards.json --- src/data/standards.json | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index 14f0fbcef627..ecc3e9c10025 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -6915,13 +6915,6 @@ "placeholder": "YOUR-COMPANY", "required": false }, - { - "type": "textField", - "name": "standards.DeployCheckChromeExtension.companyURL", - "label": "Company URL", - "placeholder": "https://yourcompany.com", - "required": false - }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.productName", From 9e44f394d5d975b69dcc1a096a101ab27720bcf9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 30 May 2026 22:49:02 +0800 Subject: [PATCH 126/164] Update worker-health.js --- src/pages/cipp/advanced/worker-health.js | 41 ++++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/pages/cipp/advanced/worker-health.js b/src/pages/cipp/advanced/worker-health.js index 19d68519852f..e22ed9f8b31d 100644 --- a/src/pages/cipp/advanced/worker-health.js +++ b/src/pages/cipp/advanced/worker-health.js @@ -128,6 +128,7 @@ const WorkerTable = ({ workers, title }) => { Min Max Last + Alloc Faults @@ -150,6 +151,13 @@ const WorkerTable = ({ workers, title }) => { {formatDuration(w.MinDurationMs)} {formatDuration(w.MaxDurationMs)} {formatDuration(w.LastDurationMs)} + + + + {w.TotalAllocMB != null ? `${w.TotalAllocMB} MB` : "—"} + + + {w.TotalFaults > 0 ? ( @@ -355,17 +363,26 @@ const CompactStatsRow = ({ snapshot }) => { label: "Memory", color: "secondary", stats: [ - { k: "Heap", v: `${mem.HeapMB ?? 0}MB` }, - { k: "RSS", v: `${mem.RssMB ?? 0}MB`, w: mem.UsagePct > 85 }, + { k: "Container", v: `${mem.ContainerUsedMB ?? mem.RssMB ?? 0} / ${mem.ContainerLimitMB ?? 0}MB`, w: mem.UsagePct > 85 }, + { k: "App RSS", v: `${mem.RssMB ?? 0}MB` }, + { k: "Other", v: `${mem.OtherRssMB ?? 0}MB` }, + { k: "GC Heap", v: `${mem.HeapMB ?? 0}MB` }, { k: "Committed", v: `${mem.CommittedMB ?? 0}MB` }, - { k: "Limit", v: `${mem.ContainerLimitMB ?? 0}MB` }, { k: "GC Limit", v: `${mem.GCHeapLimitMB ?? 0}MB` }, { k: "Usage", v: `${mem.UsagePct ?? 0}%`, w: mem.UsagePct > 85 }, - { k: "CPU", v: `${mem.CpuPct ?? 0}%`, w: mem.CpuPct > 80 }, { k: "GC", v: `${mem.GC0 ?? 0}/${mem.GC1 ?? 0}/${mem.GC2 ?? 0}` }, ...(mem.TestDataCacheCount != null ? [{ k: "Cache", v: `${mem.TestDataCacheCount} entries` }] : []), ], }, + { + label: "CPU", + color: "warning", + stats: [ + { k: "Container", v: `${mem.ContainerCpuPct ?? mem.CpuPct ?? 0}%`, w: (mem.ContainerCpuPct ?? 0) > 80 }, + { k: "App", v: `${mem.CpuPct ?? 0}%`, w: mem.CpuPct > 80 }, + { k: "Other", v: `${mem.OtherCpuPct ?? 0}%` }, + ], + }, ]; return ( @@ -599,14 +616,14 @@ const Page = () => { { icon: , name: "Memory", - data: `${snapshot.Memory?.RssMB ?? 0}MB / ${snapshot.Memory?.ContainerLimitMB ?? 0}MB (${snapshot.Memory?.UsagePct ?? 0}%)`, + data: `${snapshot.Memory?.ContainerUsedMB ?? snapshot.Memory?.RssMB ?? 0}MB / ${snapshot.Memory?.ContainerLimitMB ?? 0}MB (${snapshot.Memory?.UsagePct ?? 0}%)`, color: (snapshot.Memory?.UsagePct ?? 0) > 85 ? "error" : (snapshot.Memory?.UsagePct ?? 0) > 70 ? "warning" : "primary", }, { icon: , name: "CPU", - data: `${snapshot.Memory?.CpuPct ?? 0}%`, - color: (snapshot.Memory?.CpuPct ?? 0) > 80 ? "error" : (snapshot.Memory?.CpuPct ?? 0) > 50 ? "warning" : "primary", + data: `${snapshot.Memory?.ContainerCpuPct ?? snapshot.Memory?.CpuPct ?? 0}% container / ${snapshot.Memory?.CpuPct ?? 0}% app`, + color: (snapshot.Memory?.ContainerCpuPct ?? snapshot.Memory?.CpuPct ?? 0) > 80 ? "error" : (snapshot.Memory?.ContainerCpuPct ?? snapshot.Memory?.CpuPct ?? 0) > 50 ? "warning" : "primary", }, ]; }, [snapshot]); @@ -932,9 +949,11 @@ const Page = () => { }} /> - + + + - + )} @@ -959,7 +978,9 @@ const Page = () => { }} /> - + + + )} From 6737dcb7df0ad4e21a4edf4979dfc2f30e49510f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:08:49 +0800 Subject: [PATCH 127/164] Licence Universal Search --- .../CippCards/CippUniversalSearchV2.jsx | 128 +++++++++++- .../CippLicenseDetailsDrawer.jsx | 196 ++++++++++++++++++ src/data/M365Licenses.json | 8 - src/layouts/top-nav.js | 17 ++ src/utils/get-cipp-license-catalog.js | 88 ++++++++ src/utils/icon-registry.js | 2 + 6 files changed, 427 insertions(+), 12 deletions(-) create mode 100644 src/components/CippComponents/CippLicenseDetailsDrawer.jsx create mode 100644 src/utils/get-cipp-license-catalog.js diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index 83e127352851..b4591cca26ee 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -20,6 +20,7 @@ import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippBitlockerKeySearch } from "../CippComponents/CippBitlockerKeySearch"; import { nativeMenuItems } from "../../layouts/config"; import { usePermissions } from "../../hooks/use-permissions"; +import { searchLocalLicenseCatalog } from "../../utils/get-cipp-license-catalog"; function getLeafItems(items = []) { let result = []; @@ -111,6 +112,7 @@ export const CippUniversalSearchV2 = React.forwardRef( { onConfirm = () => {}, onChange = () => {}, + onLicenseSelect, maxResults = 10, value = "", autoFocus = false, @@ -148,6 +150,14 @@ export const CippUniversalSearchV2 = React.forwardRef( waiting: false, }); + // Local-first license lookup. The frontend ships the full Microsoft SKU + // catalog in M365Licenses.json, so for the Licenses type we match locally + // and only fall back to the API when the catalog has no hit. + const localLicenseResults = useMemo(() => { + if (searchType !== "Licenses") return []; + return searchLocalLicenseCatalog(searchValue, maxResults); + }, [searchType, searchValue, maxResults]); + const bitlockerSearch = ApiGetCall({ url: "/api/ExecBitlockerSearch", data: { @@ -272,6 +282,11 @@ export const CippUniversalSearchV2 = React.forwardRef( } else if (searchType === "Pages") { updateDropdownPosition(); setShowDropdown(true); + } else if (searchType === "Licenses") { + // Local catalog is in-memory, so reveal results as the user types. + // The API fallback still requires the Search button (handleSearch). + updateDropdownPosition(); + setShowDropdown(true); } }; @@ -331,7 +346,12 @@ export const CippUniversalSearchV2 = React.forwardRef( const handleSearch = () => { if (searchValue.length > 0) { updateDropdownPosition(); - if (searchType !== "Pages") { + if (searchType === "Licenses") { + // Only hit the API when the local catalog produced nothing. + if (localLicenseResults.length === 0) { + activeSearch?.refetch(); + } + } else if (searchType !== "Pages") { activeSearch?.refetch(); } setShowDropdown(true); @@ -361,6 +381,10 @@ export const CippUniversalSearchV2 = React.forwardRef( } } else if (searchType === "Pages") { router.push(match.path, undefined, { shallow: true }); + } else if (searchType === "Licenses") { + if (typeof onLicenseSelect === "function") { + onLicenseSelect(itemData); + } } setSearchValue(""); setShowDropdown(false); @@ -405,6 +429,11 @@ export const CippUniversalSearchV2 = React.forwardRef( icon: "Apps", onClick: () => handleTypeChange("Applications"), }, + { + label: "Licenses", + icon: "VpnKey", + onClick: () => handleTypeChange("Licenses"), + }, { label: "BitLocker", icon: "FilePresent", @@ -498,18 +527,28 @@ export const CippUniversalSearchV2 = React.forwardRef( ? bitlockerSearch.data.Results : []; const universalResults = Array.isArray(universalSearch?.data) ? universalSearch.data : []; + const licenseResults = + searchType === "Licenses" + ? localLicenseResults.length > 0 + ? localLicenseResults + : universalResults + : universalResults; const activeResults = searchType === "BitLocker" ? bitlockerResults : searchType === "Pages" ? pageResults - : universalResults; + : searchType === "Licenses" + ? licenseResults + : universalResults; const hasResults = searchType === "BitLocker" ? bitlockerResults.length > 0 : searchType === "Pages" ? pageResults.length > 0 - : universalResults.length > 0; + : searchType === "Licenses" + ? licenseResults.length > 0 + : universalResults.length > 0; const shouldShowDropdown = showDropdown && searchValue.length > 0; const getLabel = () => { @@ -523,6 +562,8 @@ export const CippUniversalSearchV2 = React.forwardRef( : "Search BitLocker by Recovery Key ID"; } else if (searchType === "Pages") { return "Search pages, tabs, paths, or scope"; + } else if (searchType === "Licenses") { + return "Search licenses by SKU ID, part number, name, or service plan"; } return "Search"; }; @@ -633,7 +674,7 @@ export const CippUniversalSearchV2 = React.forwardRef( /> ) : ( p?.servicePlanName) + .filter(Boolean) + .join(", "); + return ( + onResultClick(match)} + onMouseEnter={() => setHighlightedIndex(index)} + selected={highlightedIndex === index} + sx={{ + py: 1.5, + px: 2, + borderBottom: index < items.length - 1 ? "1px solid" : "none", + borderColor: "divider", + alignItems: "flex-start", + whiteSpace: "normal", + backgroundColor: highlightedIndex === index ? "action.selected" : "transparent", + "&:hover": { backgroundColor: "action.hover" }, + }} + > + + + {highlightMatch(itemData.displayName || itemData.skuPartNumber || "Unknown SKU")} + + {itemData.skuPartNumber && ( + + {highlightMatch(itemData.skuPartNumber)} + + )} + + } + secondary={ + + {itemData.skuId && ( + + {highlightMatch(itemData.skuId)} + + )} + + {itemData.tenantCount || 0} tenant{itemData.tenantCount === 1 ? "" : "s"} + {" · "} + {itemData.totalAssigned || 0}/{itemData.totalAvailable || 0} assigned + {servicePlans.length > 0 && ` · ${servicePlans.length} service plan${servicePlans.length === 1 ? "" : "s"}`} + + {planNames && ( + + {highlightMatch(planNames)} + + )} + + } + /> + + ); + } + return ( { + if (value === null || value === undefined || value === "") return null; + return ( + + + + {label} + + + + {String(value)} + + + + ); +}; + +export const CippLicenseDetailsDrawer = ({ data }) => { + if (!data) return null; + const fromCatalog = data.source === "catalog"; + + // For catalog-only hits, backfill tenant usage from the API by skuId. + const usageQuery = ApiGetCall({ + url: `/api/ExecUniversalSearchV2`, + data: { + searchTerms: data.skuId || data.skuPartNumber || "", + limit: 1, + type: "Licenses", + }, + queryKey: `licenseUsage-${data.skuId || data.skuPartNumber || ""}`, + waiting: fromCatalog && Boolean(data.skuId || data.skuPartNumber), + }); + + const apiMatch = (() => { + if (!fromCatalog || !usageQuery.isSuccess) return null; + const rows = Array.isArray(usageQuery.data) ? usageQuery.data : []; + const target = String(data.skuId || "").toLowerCase(); + const hit = + rows.find((r) => String(r?.Data?.skuId || "").toLowerCase() === target) || rows[0]; + return hit?.Data || null; + })(); + + const merged = apiMatch + ? { + ...data, + tenantCount: apiMatch.tenantCount ?? data.tenantCount, + totalAssigned: apiMatch.totalAssigned ?? data.totalAssigned, + totalAvailable: apiMatch.totalAvailable ?? data.totalAvailable, + tenants: Array.isArray(apiMatch.tenants) ? apiMatch.tenants : [], + } + : data; + + const servicePlans = Array.isArray(merged.servicePlans) ? merged.servicePlans : []; + const tenants = Array.isArray(merged.tenants) ? merged.tenants : []; + const hasUsage = !fromCatalog || apiMatch !== null; + const usageLoading = fromCatalog && usageQuery.isFetching && !apiMatch; + const usageNotFound = fromCatalog && usageQuery.isSuccess && !apiMatch; + + return ( + + + + {merged.displayName || merged.skuPartNumber || "License"} + + + {hasUsage + ? `${merged.tenantCount || 0} tenant${merged.tenantCount === 1 ? "" : "s"} · ${ + merged.totalAssigned || 0 + }/${merged.totalAvailable || 0} assigned` + : usageLoading + ? "Microsoft published catalog · loading tenant usage…" + : usageNotFound + ? "Microsoft published catalog · not assigned in any tenant" + : "Microsoft published catalog"} + + + + + + + + {hasUsage && ( + <> + + + + + )} + + {usageLoading && ( + + + + Looking up tenant usage… + + + )} + + {servicePlans.length > 0 && ( + <> + + Service plans ({servicePlans.length}) + + + + + + Name + Friendly name + Service plan ID + + + + + {servicePlans.map((plan, idx) => { + const id = plan.servicePlanId || plan.servicePlanid || ""; + const name = plan.servicePlanName || ""; + const friendly = plan.friendlyName || ""; + return ( + + {name} + {friendly} + {id} + {id && } + + ); + })} + +
+
+ + )} + + {tenants.length > 0 && ( + <> + + + Tenants ({tenants.length}) + + + + + + Tenant + Used + Total + + + + + {tenants.map((t, idx) => ( + + {t.tenant} + {t.used ?? "-"} + {t.total ?? "-"} + + {t.tenant && } + + + ))} + +
+
+ + )} +
+ ); +}; diff --git a/src/data/M365Licenses.json b/src/data/M365Licenses.json index 3f725239335b..0dfed3375a94 100644 --- a/src/data/M365Licenses.json +++ b/src/data/M365Licenses.json @@ -11151,14 +11151,6 @@ "Service_Plan_Id": "ded3d325-1bdc-453e-8432-5bac26d7a014", "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" }, - { - "Product_Display_Name": "Microsoft 365 Business Premium", - "String_Id": "SPB", - "GUID": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", - "Service_Plan_Name": "SHAREPOINTSTANDARD", - "Service_Plan_Id": "c7699d2e-19aa-44de-8edf-1736da088ca1", - "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 1)" - }, { "Product_Display_Name": "Microsoft 365 Business Premium", "String_Id": "SPB", diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index baa5d1ad9f9f..10bdcefd9581 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -41,6 +41,8 @@ import { CippTenantSelector } from '../components/CippComponents/CippTenantSelec import { NotificationsPopover } from './notifications-popover' import { useDialog } from '../hooks/use-dialog' import { CippUniversalSearchV2 } from '../components/CippCards/CippUniversalSearchV2' +import { CippOffCanvas } from '../components/CippComponents/CippOffCanvas' +import { CippLicenseDetailsDrawer } from '../components/CippComponents/CippLicenseDetailsDrawer' const TOP_NAV_HEIGHT = 64 @@ -69,6 +71,8 @@ export const TopNav = (props) => { const [flashSort, setFlashSort] = useState(false) const [flashLock, setFlashLock] = useState(false) const [universalSearchKey, setUniversalSearchKey] = useState(0) + const [licenseDrawerVisible, setLicenseDrawerVisible] = useState(false) + const [licenseDrawerData, setLicenseDrawerData] = useState(null) const [universalSearchDefaultType, setUniversalSearchDefaultType] = useState('Users') const itemRefs = useRef({}) const touchDragRef = useRef({ startIdx: null, overIdx: null }) @@ -633,10 +637,23 @@ export const TopNav = (props) => { autoFocus={true} defaultSearchType={universalSearchDefaultType} onConfirm={closeUniversalSearch} + onLicenseSelect={(licenseData) => { + setLicenseDrawerData(licenseData) + setLicenseDrawerVisible(true) + }} />
+ setLicenseDrawerVisible(false)} + size="xl" + contentPadding={0} + > + + { + const map = new Map(); + for (const row of [...M365LicensesDefault, ...M365LicensesAdditional]) { + if (!row?.GUID) continue; + const key = row.GUID.toLowerCase(); + let entry = map.get(key); + if (!entry) { + entry = { + skuId: row.GUID, + skuPartNumber: row.String_Id || "", + displayName: row.Product_Display_Name || row.String_Id || "", + servicePlans: [], + _planSet: new Set(), + }; + map.set(key, entry); + } + if (!entry.skuPartNumber && row.String_Id) entry.skuPartNumber = row.String_Id; + if (!entry.displayName && row.Product_Display_Name) entry.displayName = row.Product_Display_Name; + + if (row.Service_Plan_Id) { + const planKey = row.Service_Plan_Id.toLowerCase(); + if (!entry._planSet.has(planKey)) { + entry._planSet.add(planKey); + entry.servicePlans.push({ + servicePlanId: row.Service_Plan_Id, + servicePlanName: row.Service_Plan_Name || "", + friendlyName: row.Service_Plans_Included_Friendly_Names || "", + }); + } + } + } + // Drop the helper Set before exposing entries. + return Array.from(map.values()).map(({ _planSet, ...rest }) => rest); +}; + +const getCatalog = () => { + if (!catalogCache) catalogCache = buildCatalog(); + return catalogCache; +}; + +/** + * Search the local M365 license catalog. Matches against skuId (GUID), + * skuPartNumber (String_Id), display name, and any service plan name or + * friendly name. Returns up to `limit` results in the same envelope shape + * Universal Search uses for API results. + */ +export const searchLocalLicenseCatalog = (query, limit = 10) => { + if (!query || typeof query !== "string") return []; + const q = query.trim().toLowerCase(); + if (!q) return []; + + const matches = []; + for (const entry of getCatalog()) { + if (matches.length >= limit) break; + const haystacks = [ + entry.skuId, + entry.skuPartNumber, + entry.displayName, + ...entry.servicePlans.flatMap((p) => [p.servicePlanName, p.friendlyName]), + ]; + if (haystacks.some((v) => v && v.toLowerCase().includes(q))) { + matches.push({ + Tenant: "", + Type: "Licenses", + RowKey: `Licenses-${entry.skuId}`, + Data: { + skuId: entry.skuId, + skuPartNumber: entry.skuPartNumber, + displayName: entry.displayName, + servicePlans: entry.servicePlans, + tenantCount: 0, + totalAssigned: 0, + totalAvailable: 0, + tenants: [], + source: "catalog", + }, + }); + } + } + return matches; +}; diff --git a/src/utils/icon-registry.js b/src/utils/icon-registry.js index 2863958edbb4..1eeabb7f01b3 100644 --- a/src/utils/icon-registry.js +++ b/src/utils/icon-registry.js @@ -41,6 +41,7 @@ import { Timeline, Window, Warning, + VpnKey, } from '@mui/icons-material' export const iconRegistry = { @@ -86,6 +87,7 @@ export const iconRegistry = { Timeline, Window, Warning, + VpnKey, } export const getIconComponentByName = (iconName) => iconRegistry[iconName] ?? null From e2c39b26d95394b3095a28f03e9dccb7a8085afa Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:09:49 +0800 Subject: [PATCH 128/164] Update M365Licenses.json --- src/data/M365Licenses.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/data/M365Licenses.json b/src/data/M365Licenses.json index 0dfed3375a94..3f725239335b 100644 --- a/src/data/M365Licenses.json +++ b/src/data/M365Licenses.json @@ -11151,6 +11151,14 @@ "Service_Plan_Id": "ded3d325-1bdc-453e-8432-5bac26d7a014", "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" }, + { + "Product_Display_Name": "Microsoft 365 Business Premium", + "String_Id": "SPB", + "GUID": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", + "Service_Plan_Name": "SHAREPOINTSTANDARD", + "Service_Plan_Id": "c7699d2e-19aa-44de-8edf-1736da088ca1", + "Service_Plans_Included_Friendly_Names": "SharePoint (Plan 1)" + }, { "Product_Display_Name": "Microsoft 365 Business Premium", "String_Id": "SPB", From 3734adeeaec92e6d83974590c36a9e91bf024a98 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:44:55 +0200 Subject: [PATCH 129/164] Fix template trigger --- src/pages/tenant/manage/user-defaults.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 3a5c3a150899..62f5250922df 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -122,7 +122,7 @@ const Page = () => { api: { url: '/api/ListLicenses', labelField: (option) => - `${option.License || option.skuPartNumber} (${option.AvailableUnits || 0} available)`, + `${option.License || option.skuPartNumber} (${option.availableUnits || 0} available)`, valueField: 'skuId', queryKey: `ListLicenses-${userSettings.currentTenant}`, }, From 49cda6e0b465ad7bb9a0a259a9ee31a2584e0835 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:28:27 +0800 Subject: [PATCH 130/164] Update index.js --- src/pages/tenant/reports/list-licenses/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tenant/reports/list-licenses/index.js b/src/pages/tenant/reports/list-licenses/index.js index 4d877df75f8f..61fcfc5b74b6 100644 --- a/src/pages/tenant/reports/list-licenses/index.js +++ b/src/pages/tenant/reports/list-licenses/index.js @@ -5,7 +5,7 @@ import CippFormComponent from '../../../../components/CippComponents/CippFormCom const Page = () => { const pageTitle = 'Licences Report' - const apiUrl = '/api/ListLicenses' + const apiUrl = '/api/ListLicensesReport' const simpleColumns = [ 'Tenant', From f1703f041f517d7d07ca35074a89743f1991141f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:15:54 +0800 Subject: [PATCH 131/164] Update CippTenantModeDeploy.jsx --- src/components/CippWizard/CippTenantModeDeploy.jsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx index c14bb0aa573b..c1f5b2669c43 100644 --- a/src/components/CippWizard/CippTenantModeDeploy.jsx +++ b/src/components/CippWizard/CippTenantModeDeploy.jsx @@ -47,6 +47,13 @@ export const CippTenantModeDeploy = (props) => { } }, [updateRefreshToken.isSuccess, formControl, addTenant.isSuccess]); + useEffect(() => { + if (partnerTenantInfo?.data?.authenticatedUserPrincipalName) { + formControl.setValue("GDAPAuth", true); + formControl.trigger("GDAPAuth"); + } + }, [partnerTenantInfo?.data?.authenticatedUserPrincipalName, formControl]); + return ( {/* Partner Tenant (GDAP) */} From 89abbf507b2f96ea9a9238536bd95fe99a1b3b37 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:03:13 +0200 Subject: [PATCH 132/164] fix: improve stale issue and close messages for clarity --- .github/workflows/Close_Stale_Issues.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Close_Stale_Issues.yml b/.github/workflows/Close_Stale_Issues.yml index b1878078ac90..ec88789e827d 100644 --- a/.github/workflows/Close_Stale_Issues.yml +++ b/.github/workflows/Close_Stale_Issues.yml @@ -10,8 +10,8 @@ jobs: steps: - uses: actions/stale@v10 with: - stale-issue-message: "This issue is stale because it has been open 10 days with no activity. We will close this issue soon. If you want this feature implemented you can contribute it. See: https://docs.cipp.app/dev-documentation/contributing-to-the-code . Please notify the team if you are working on this yourself." - close-issue-message: "This issue was closed because it has been stalled for 14 days with no activity." + stale-issue-message: "This issue is stale because it has been open for 10 days with no activity. Please do not bump feature requests unless you are actively working on them, as bumps interfere with our triage process and make it harder to maintain a current list of feature requests. If you want this feature implemented, you can contribute it yourself; see https://docs.cipp.app/dev-documentation/contributing-to-the-code. Please notify the team if you are working on this." + close-issue-message: "This issue was closed because it has been stalled for 14 days without activity. We auto-close inactive feature requests to keep the backlog focused and actionable. If this request is still needed, you may submit it again after 30 days." stale-issue-label: "no-activity" exempt-issue-labels: "planned,bug,roadmap" days-before-stale: 9 From d4570de5058eba87c7f9e122a39d41bdedbc401c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:10:49 +0800 Subject: [PATCH 133/164] Update CippReportToolbar.jsx --- .../CippComponents/CippReportToolbar.jsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/components/CippComponents/CippReportToolbar.jsx b/src/components/CippComponents/CippReportToolbar.jsx index 7a25a9976075..bc7a9274a56c 100644 --- a/src/components/CippComponents/CippReportToolbar.jsx +++ b/src/components/CippComponents/CippReportToolbar.jsx @@ -109,13 +109,6 @@ export const CippReportToolbar = () => { onClick={() => { setRefreshDialog({ open: true, - title: 'Refresh Test Data', - message: `Are you sure you want to refresh the test data for ${currentTenant}? This might take up to 2 hours to update.`, - api: { - url: '/api/ExecTestRun', - data: { tenantFilter: currentTenant }, - method: 'POST', - }, handleClose: () => setRefreshDialog({ open: false }), }) }} @@ -187,13 +180,26 @@ export const CippReportToolbar = () => { From cbd6faefb184a7ee611063726862d2663bb56d4b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:15:25 +0800 Subject: [PATCH 134/164] Correct report builder permissions --- src/pages/tools/report-builder/builder/index.js | 4 ++-- src/pages/tools/report-builder/view/index.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/tools/report-builder/builder/index.js b/src/pages/tools/report-builder/builder/index.js index ed99486b4fb4..fa9df1b354fa 100644 --- a/src/pages/tools/report-builder/builder/index.js +++ b/src/pages/tools/report-builder/builder/index.js @@ -764,7 +764,7 @@ const Page = () => { /* ── API hooks ── */ const templatesApi = ApiGetCall({ - url: '/api/ListReportBuilderTemplates', + url: '/api/ListReportBuilderTemplates?tenantFilter=' + currentTenant, queryKey: `ListReportBuilderTemplates-builder-${templateId}`, waiting: !!templateId, }) @@ -1065,7 +1065,7 @@ const Page = () => { const name = saveForm.getValues('templateName') if (!name?.trim()) return saveTemplateCall.mutate({ - url: '/api/ExecReportBuilderTemplate', + url: '/api/ExecReportBuilderTemplate?tenantFilter=' + currentTenant, data: { Action: 'save', GUID: templateGUID || undefined, diff --git a/src/pages/tools/report-builder/view/index.js b/src/pages/tools/report-builder/view/index.js index d1bc6430637a..51a0dd058767 100644 --- a/src/pages/tools/report-builder/view/index.js +++ b/src/pages/tools/report-builder/view/index.js @@ -32,7 +32,7 @@ const Page = () => { }, [router.isReady, router.query.id]) const reportApi = ApiGetCall({ - url: '/api/ListGeneratedReports', + url: '/api/ListGeneratedReports?tenantFilter=' + settings.currentTenant, data: { ReportGUID: reportId }, queryKey: `ListGeneratedReports-${reportId}`, waiting: !!reportId, From 4d88e456cf5e581dbbd30cc6e348059e410d7f9c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:49:31 +0800 Subject: [PATCH 135/164] Update CippAutocomplete.jsx --- src/components/CippComponents/CippAutocomplete.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 8afcb74d7415..075f47ed29a1 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -418,7 +418,11 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { }) newValue = newValue.filter( (item) => - item.value && item.value !== '' && item.value !== 'error' && item.value !== -1 + item.value !== null && + item.value !== undefined && + item.value !== '' && + item.value !== 'error' && + item.value !== -1 ) } else { if (newValue?.manual || !newValue?.label) { @@ -430,7 +434,7 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { newValue = onCreateOption(newValue, newValue?.addedFields) } } - if (!newValue?.value || newValue.value === 'error') { + if (newValue?.value === null || newValue?.value === undefined || newValue?.value === '' || newValue.value === 'error') { newValue = null } } From 51d2828b77990f8a3d017d69206c7b0a3df0f832 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:37:01 +0200 Subject: [PATCH 136/164] add mcp allowed --- .../CippApiClientManagement.jsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/CippIntegrations/CippApiClientManagement.jsx b/src/components/CippIntegrations/CippApiClientManagement.jsx index a9a2d2960ef1..539295619a88 100644 --- a/src/components/CippIntegrations/CippApiClientManagement.jsx +++ b/src/components/CippIntegrations/CippApiClientManagement.jsx @@ -179,6 +179,11 @@ const CippApiClientManagement = () => { name: "Enabled", label: "Enable this client", }, + { + type: "switch", + name: "MCPAllowed", + label: "MCP Access Allowed", + }, ], type: "POST", url: "/api/ExecApiClient", @@ -363,7 +368,7 @@ const CippApiClientManagement = () => { data: { Action: "List" }, dataKey: "Results", }} - simpleColumns={["Enabled", "AppName", "ClientId", "Role", "IPRange"]} + simpleColumns={["Enabled", "MCPAllowed", "AppName", "ClientId", "Role", "IPRange"]} queryKey={`ApiClients`} /> @@ -417,6 +422,11 @@ const CippApiClientManagement = () => { name: "Enabled", label: "Enable this client", }, + { + type: "switch", + name: "MCPAllowed", + label: "MCP Access Allowed", + }, ]} api={{ type: "POST", @@ -486,6 +496,11 @@ const CippApiClientManagement = () => { name: "Enabled", label: "Enable this client", }, + { + type: "switch", + name: "MCPAllowed", + label: "MCP Access Allowed", + }, ]} api={{ type: "POST", From e4009f28e0aeb0f338cfeeed606b24fec26b05b7 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:54:12 +0200 Subject: [PATCH 137/164] feat: add Email as alternate login ID standard --- src/data/standards.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/data/standards.json b/src/data/standards.json index ecc3e9c10025..fc2b40fc34ec 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -1680,6 +1680,28 @@ "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", "recommendedBy": [] }, + { + "name": "standards.EmailAsAlternateLoginId", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures the tenant-wide Email as alternate login ID setting in Home Realm Discovery policy. Enabling this can help during migrations, if users are changing UPN.", + "docsDescription": "Sets the Home Realm Discovery policy AlternateIdLogin setting to enable or disable using email as an alternate sign-in ID.", + "executiveText": "Controls whether users can sign in with email as an alternate identifier, allowing organizations to align sign-in behavior with their identity strategy and reduce authentication ambiguity.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.EmailAsAlternateLoginId.Enabled", + "label": "Enable Email as Alternate Login ID", + "defaultValue": true + } + ], + "label": "Configure Email as alternate login ID", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-06-03", + "powershellEquivalent": "Invoke-MgGraphRequest https://graph.microsoft.com/v1.0/policies/homeRealmDiscoveryPolicies/", + "recommendedBy": ["CIPP"] + }, { "name": "standards.Disablex509Certificate", "cat": "Entra (AAD) Standards", From 38ac0c4a46f6c1e5f405bd429bae7accd43a1917 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:25:42 +0200 Subject: [PATCH 138/164] MCP warning --- .../CippComponents/CippFormComponent.jsx | 8 +++++++ .../CippApiClientManagement.jsx | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 638d67b50e81..049337ccaf25 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -12,6 +12,7 @@ import { Box, Input, Tooltip, + Alert, } from "@mui/material"; import { CippAutoComplete } from "./CippAutocomplete"; import { CippTextFieldWithVariables } from "./CippTextFieldWithVariables"; @@ -94,6 +95,13 @@ export const CippFormComponent = (props) => { ); + case "alert": + return ( + + {label} + + ); + case "hidden": return ( { name: "MCPAllowed", label: "MCP Access Allowed", }, + { + type: "alert", + name: "mcpAccessWarning", + severity: "warning", + label: + "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role.", + }, ], type: "POST", url: "/api/ExecApiClient", @@ -427,6 +434,13 @@ const CippApiClientManagement = () => { name: "MCPAllowed", label: "MCP Access Allowed", }, + { + type: "alert", + name: "mcpAccessWarning", + severity: "warning", + label: + "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role.", + }, ]} api={{ type: "POST", @@ -501,6 +515,13 @@ const CippApiClientManagement = () => { name: "MCPAllowed", label: "MCP Access Allowed", }, + { + type: "alert", + name: "mcpAccessWarning", + severity: "warning", + label: + "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role.", + }, ]} api={{ type: "POST", From b2f8f80372d5c6a28ef57ea373708cd3287f3a8a Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:24:53 +0200 Subject: [PATCH 139/164] feat: add actions for managing mailbox client access protocols --- .../reports/mailbox-cas-settings/index.js | 109 +++++++++++++++--- 1 file changed, 91 insertions(+), 18 deletions(-) diff --git a/src/pages/email/reports/mailbox-cas-settings/index.js b/src/pages/email/reports/mailbox-cas-settings/index.js index 1058c83e93b2..5ae9d75cc3d8 100644 --- a/src/pages/email/reports/mailbox-cas-settings/index.js +++ b/src/pages/email/reports/mailbox-cas-settings/index.js @@ -1,29 +1,102 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { useSettings } from '../../../../hooks/use-settings.js' +import { Tune } from '@mui/icons-material' + +// CAS protocols that can be toggled, keyed by their Set-CASMailbox parameter. +// Most flags follow "*Enabled" (true = on). The inverted "*Disabled" flag +// (SMTP client auth) is the opposite: true = off. +const casProtocols = { + ECPEnabled: 'ECP (Exchange Control Panel)', + EWSEnabled: 'EWS (Exchange Web Services)', + IMAPEnabled: 'IMAP', + MAPIEnabled: 'MAPI', + OWAEnabled: 'OWA (Outlook on the Web)', + POPEnabled: 'POP', + ActiveSyncEnabled: 'ActiveSync', + SmtpClientAuthenticationDisabled: 'SMTP Client Authentication', +} const Page = () => { + const tenantFilter = useSettings().currentTenant + + // A single action lets the operator pick which protocols to enable or disable. + // Each selected protocol becomes a Set-CASMailbox flag, accounting for the inverted + // "*Disabled" parameter, and one request is sent per selected mailbox. + const actions = [ + { + label: 'Set Client Access Protocols', + type: 'POST', + icon: , + url: '/api/ExecSetCASMailbox', + fields: [ + { + type: 'radio', + name: 'enable', + label: 'Action', + options: [ + { label: 'Enable', value: true }, + { label: 'Disable', value: false }, + ], + validators: { required: 'Please choose whether to enable or disable' }, + }, + { + type: 'autoComplete', + name: 'protocols', + label: 'Protocols', + multiple: true, + creatable: false, + options: Object.entries(casProtocols).map(([value, label]) => ({ label, value })), + validators: { required: 'Please select at least one protocol' }, + }, + ], + confirmText: + 'Enable or disable the selected client access protocols for the chosen mailbox(es).', + customDataformatter: (rows, action, formData) => { + const mailboxes = Array.isArray(rows) ? rows : [rows] + const rawEnable = + typeof formData.enable === 'object' ? formData.enable?.value : formData.enable + const enable = rawEnable === true || rawEnable === 'true' + const protocolFlags = (formData.protocols ?? []).reduce((flags, selection) => { + const param = typeof selection === 'object' ? selection.value : selection + // "*Disabled" params are inverted: enabling the protocol means setting them false. + // SMTP client auth is disable-only; the API rejects an enable attempt with a message. + flags[param] = param.endsWith('Disabled') ? !enable : enable + return flags + }, {}) + + return mailboxes.map((row) => ({ + tenantFilter, + Identity: row.Guid, + DisplayName: row.DisplayName, + ...protocolFlags, + })) + }, + color: 'info', + }, + ] + return ( - ); -}; - -// No actions were specified in the original code, so no actions are added here. -// No off-canvas configuration was provided or specified in the original code. + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page From 692c67d6451811f01fdcd7fd021a9b9273efd233 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 3 Jun 2026 21:33:18 -0400 Subject: [PATCH 140/164] fix: quarantine deny action fixes #5930 --- src/pages/email/administration/quarantine/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/email/administration/quarantine/index.js b/src/pages/email/administration/quarantine/index.js index 7443eb5e714f..a52606123181 100644 --- a/src/pages/email/administration/quarantine/index.js +++ b/src/pages/email/administration/quarantine/index.js @@ -126,6 +126,7 @@ const Page = () => { data: { Identity: 'Identity', Type: '!Deny', + RecipientAddress: 'RecipientAddress', }, confirmText: 'Are you sure you want to deny this message?', icon: , From a80484765aea42b43c1e663d0a9a9295f3795ad9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:03:41 +0800 Subject: [PATCH 141/164] Exclude partner tenant --- src/components/CippComponents/CippAddEditTenantGroups.jsx | 6 ++++++ src/pages/tenant/administration/tenants/groups/edit.js | 1 + 2 files changed, 7 insertions(+) diff --git a/src/components/CippComponents/CippAddEditTenantGroups.jsx b/src/components/CippComponents/CippAddEditTenantGroups.jsx index 4208c18eaf7c..2ead662a5cd5 100644 --- a/src/components/CippComponents/CippAddEditTenantGroups.jsx +++ b/src/components/CippComponents/CippAddEditTenantGroups.jsx @@ -83,6 +83,12 @@ const CippAddEditTenantGroups = ({ formControl, initialValues, title, backButton compareType="is" compareValue="dynamic" > + { groupDescription: groupData?.Description ?? "", groupType: isDynamic ? "dynamic" : "static", ruleLogic: groupData?.RuleLogic || "and", + excludePartnerTenant: groupData?.ExcludePartnerTenant ?? false, members: !isDynamic ? groupData?.Members?.map((member) => ({ label: member.displayName, From 726232611922cf2c302a7e77254ba881e074f4d8 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:21:35 +0200 Subject: [PATCH 142/164] add excludeFromAlert to licenses. --- src/pages/cipp/settings/licenses.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index d734f7eac437..2bada2c1f8f8 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -4,7 +4,7 @@ import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; import { Button, SvgIcon, Stack, Box } from "@mui/material"; import { TrashIcon } from "@heroicons/react/24/outline"; -import { Add, RestartAlt } from "@mui/icons-material"; +import { Add, RestartAlt, NotificationsOff } from "@mui/icons-material"; import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; import { useDialog } from "../../../hooks/use-dialog"; import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; @@ -18,7 +18,7 @@ const Page = () => { const apiUrl = "/api/ListExcludedLicenses"; const createDialog = useDialog(); const resetDialog = useDialog(); - const simpleColumns = ["Product_Display_Name", "GUID"]; + const simpleColumns = ["Product_Display_Name", "GUID", "ExclusionType"]; const allLicenseOptions = useMemo(() => { const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional]; @@ -49,6 +49,15 @@ const Page = () => { }, []); const actions = [ + { + label: "Only Exclude from Alerts", + type: "POST", + url: "/api/ExecExcludeLicenses", + data: { Action: "!AlertOnly", GUID: "GUID", SKUName: "Product_Display_Name" }, + confirmText: + "This license will remain visible in CIPP but will be excluded from alerts. Continue?", + icon: , + }, { label: "Delete Exclusion", type: "POST", @@ -94,7 +103,7 @@ const Page = () => { }; const offCanvas = { - extendedInfoFields: ["Product_Display_Name", "GUID"], + extendedInfoFields: ["Product_Display_Name", "GUID", "ExclusionType"], actions: actions, }; From cf30b4b19c0feee4b4429777e51b1c06720d51c0 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:25:03 +0200 Subject: [PATCH 143/164] add excluded from alerts to licenses --- src/pages/cipp/settings/licenses.js | 140 ++++++++++++++-------------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index 2bada2c1f8f8..d8b033cbdbd1 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -1,28 +1,28 @@ -import tabOptions from "./tabOptions"; -import { TabbedLayout } from "../../../layouts/TabbedLayout"; -import { Layout as DashboardLayout } from "../../../layouts/index.js"; -import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; -import { Button, SvgIcon, Stack, Box } from "@mui/material"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { Add, RestartAlt, NotificationsOff } from "@mui/icons-material"; -import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; -import { useDialog } from "../../../hooks/use-dialog"; -import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; -import { CippFormCondition } from "../../../components/CippComponents/CippFormCondition"; -import M365LicensesDefault from "../../../data/M365Licenses.json"; -import M365LicensesAdditional from "../../../data/M365Licenses-additional.json"; -import { useMemo, useCallback } from "react"; +import tabOptions from './tabOptions' +import { TabbedLayout } from '../../../layouts/TabbedLayout' +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx' +import { Button, SvgIcon, Stack, Box } from '@mui/material' +import { TrashIcon } from '@heroicons/react/24/outline' +import { Add, RestartAlt, NotificationsOff } from '@mui/icons-material' +import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog' +import { useDialog } from '../../../hooks/use-dialog' +import CippFormComponent from '../../../components/CippComponents/CippFormComponent' +import { CippFormCondition } from '../../../components/CippComponents/CippFormCondition' +import M365LicensesDefault from '../../../data/M365Licenses.json' +import M365LicensesAdditional from '../../../data/M365Licenses-additional.json' +import { useMemo, useCallback } from 'react' const Page = () => { - const pageTitle = "Excluded Licenses"; - const apiUrl = "/api/ListExcludedLicenses"; - const createDialog = useDialog(); - const resetDialog = useDialog(); - const simpleColumns = ["Product_Display_Name", "GUID", "ExclusionType"]; + const pageTitle = 'Excluded Licenses' + const apiUrl = '/api/ListExcludedLicenses' + const createDialog = useDialog() + const resetDialog = useDialog() + const simpleColumns = ['Product_Display_Name', 'GUID', 'ExclusionType'] const allLicenseOptions = useMemo(() => { - const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional]; - const uniqueLicenses = new Map(); + const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional] + const uniqueLicenses = new Map() allLicenses.forEach((license) => { if (license.GUID && license.Product_Display_Name) { @@ -30,48 +30,48 @@ const Page = () => { uniqueLicenses.set(license.GUID, { label: license.Product_Display_Name, value: license.GUID, - }); + }) } } - }); + }) - const options = Array.from(uniqueLicenses.values()); - const nameCounts = {}; + const options = Array.from(uniqueLicenses.values()) + const nameCounts = {} options.forEach((opt) => { - nameCounts[opt.label] = (nameCounts[opt.label] || 0) + 1; - }); + nameCounts[opt.label] = (nameCounts[opt.label] || 0) + 1 + }) return options .map((opt) => nameCounts[opt.label] > 1 ? { ...opt, label: `${opt.label} (${opt.value})` } : opt ) - .sort((a, b) => a.label.localeCompare(b.label)); - }, []); + .sort((a, b) => a.label.localeCompare(b.label)) + }, []) const actions = [ { - label: "Only Exclude from Alerts", - type: "POST", - url: "/api/ExecExcludeLicenses", - data: { Action: "!AlertOnly", GUID: "GUID", SKUName: "Product_Display_Name" }, + label: 'Only Exclude from Alerts', + type: 'POST', + url: '/api/ExecExcludeLicenses', + data: { Action: '!AlertOnly', GUID: 'GUID', SKUName: 'Product_Display_Name' }, confirmText: - "This license will remain visible in CIPP but will be excluded from alerts. Continue?", + 'This license will remain visible in CIPP but will be excluded from alerts. Continue?', icon: , }, { - label: "Delete Exclusion", - type: "POST", - url: "/api/ExecExcludeLicenses", - data: { Action: "!RemoveExclusion", GUID: "GUID" }, - confirmText: "Do you want to delete this exclusion?", - color: "error", + label: 'Delete Exclusion', + type: 'POST', + url: '/api/ExecExcludeLicenses', + data: { Action: '!RemoveExclusion', GUID: 'GUID' }, + confirmText: 'Do you want to delete this exclusion?', + color: 'error', icon: ( ), }, - ]; + ] const CardButtons = () => { return ( @@ -99,28 +99,28 @@ const Page = () => { Restore Defaults - ); - }; + ) + } const offCanvas = { - extendedInfoFields: ["Product_Display_Name", "GUID", "ExclusionType"], + extendedInfoFields: ['Product_Display_Name', 'GUID', 'ExclusionType'], actions: actions, - }; + } const addExclusionFormatter = useCallback((row, action, formData) => { if (formData.advancedMode) { return { - Action: "AddExclusion", + Action: 'AddExclusion', GUID: formData.GUID, SKUName: formData.SKUName, - }; + } } return { - Action: "AddExclusion", + Action: 'AddExclusion', GUID: formData.selectedLicense?.value, SKUName: formData.selectedLicense?.label, - }; - }, []); + } + }, []) return ( <> @@ -139,13 +139,13 @@ const Page = () => { title="Add Excluded License" createDialog={createDialog} api={{ - url: "/api/ExecExcludeLicenses", + url: '/api/ExecExcludeLicenses', confirmText: - "Add a license to the exclusion table. Select from the list or use Advanced Mode to enter a custom GUID.", - type: "POST", - data: { Action: "!AddExclusion" }, - replacementBehaviour: "removeNulls", - relatedQueryKeys: ["ExcludedLicenses"], + 'Add a license to the exclusion table. Select from the list or use Advanced Mode to enter a custom GUID.', + type: 'POST', + data: { Action: '!AddExclusion' }, + replacementBehaviour: 'removeNulls', + relatedQueryKeys: ['ExcludedLicenses'], customDataformatter: addExclusionFormatter, }} > @@ -174,7 +174,7 @@ const Page = () => { formControl={formHook} multiple={false} creatable={false} - validators={{ required: "Please select a license" }} + validators={{ required: 'Please select a license' }} /> @@ -191,7 +191,7 @@ const Page = () => { label="GUID" formControl={formHook} disableVariables={true} - validators={{ required: "GUID is required" }} + validators={{ required: 'GUID is required' }} /> { label="SKU Name" formControl={formHook} disableVariables={true} - validators={{ required: "SKU Name is required" }} + validators={{ required: 'SKU Name is required' }} /> @@ -211,28 +211,28 @@ const Page = () => { createDialog={resetDialog} fields={[ { - type: "switch", - name: "FullReset", - label: "Full Reset (clear all entries including manually added ones)", + type: 'switch', + name: 'FullReset', + label: 'Full Reset (clear all entries including manually added ones)', }, ]} api={{ - url: "/api/ExecExcludeLicenses", + url: '/api/ExecExcludeLicenses', confirmText: "This will restore default licenses from the config file. If 'Full Reset' is enabled, all existing entries will be cleared first.", - type: "POST", - data: { Action: "!RestoreDefaults" }, - relatedQueryKeys: ["ExcludedLicenses"], + type: 'POST', + data: { Action: '!RestoreDefaults' }, + relatedQueryKeys: ['ExcludedLicenses'], }} /> - ); -}; + ) +} Page.getLayout = (page) => ( {page} -); +) -export default Page; +export default Page From 11ecd3342338bf46f214a62137d6ad4482410ab3 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:25:26 +0200 Subject: [PATCH 144/164] remove unneeded results key --- .../CippFormLicenseSelector.jsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/components/CippComponents/CippFormLicenseSelector.jsx b/src/components/CippComponents/CippFormLicenseSelector.jsx index 8ec20ec6478a..cbc6455beaeb 100644 --- a/src/components/CippComponents/CippFormLicenseSelector.jsx +++ b/src/components/CippComponents/CippFormLicenseSelector.jsx @@ -1,6 +1,6 @@ -import { CippFormComponent } from "./CippFormComponent"; -import { getCippLicenseTranslation } from "../../utils/get-cipp-license-translation"; -import { useSettings } from "../../hooks/use-settings"; +import { CippFormComponent } from './CippFormComponent' +import { getCippLicenseTranslation } from '../../utils/get-cipp-license-translation' +import { useSettings } from '../../hooks/use-settings' export const CippFormLicenseSelector = ({ formControl, @@ -12,7 +12,7 @@ export const CippFormLicenseSelector = ({ showRefresh = false, ...other }) => { - const userSettingsDefaults = useSettings(); + const userSettingsDefaults = useSettings() return ( `${getCippLicenseTranslation([option])} (${option?.availableUnits} available)`, - valueField: "skuId", + valueField: 'skuId', queryKey: `ListLicenses-${userSettingsDefaults?.currentTenant ?? undefined}`, data: { - Endpoint: "subscribedSkus", + Endpoint: 'subscribedSkus', $count: true, }, showRefresh, }} /> - ); -}; + ) +} From 3daf8b33cad3a4c9df81c87703758cab2d3150aa Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:49:15 +0800 Subject: [PATCH 145/164] Add named location editing to CA template editor --- .../CippComponents/CippCAPolicyBuilder.jsx | 335 +++++++++++++++++- .../conditional/list-template/create.jsx | 2 +- .../tenant/conditional/list-template/edit.jsx | 1 + 3 files changed, 335 insertions(+), 3 deletions(-) diff --git a/src/components/CippComponents/CippCAPolicyBuilder.jsx b/src/components/CippComponents/CippCAPolicyBuilder.jsx index 30a38673bd4e..ed17aae9375b 100644 --- a/src/components/CippComponents/CippCAPolicyBuilder.jsx +++ b/src/components/CippComponents/CippCAPolicyBuilder.jsx @@ -11,16 +11,22 @@ import { Tooltip, IconButton, Paper, + Button, + Box, } from "@mui/material"; import { Grid } from "@mui/system"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; -import { useWatch } from "react-hook-form"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import PublicIcon from "@mui/icons-material/Public"; +import { useWatch, useFieldArray } from "react-hook-form"; import CippFormComponent from "./CippFormComponent"; import { CippFormCondition } from "./CippFormCondition"; import caSchema from "../../data/conditionalAccessSchema.json"; import gdapRoles from "../../data/GDAPRoles.json"; +import countryList from "../../data/countryList.json"; /** * CippCAPolicyBuilder — A schema-driven Conditional Access policy builder. @@ -840,10 +846,290 @@ function SessionControlsSection({ formControl, disabled }) { ); } +// --------------------------------------------------------------------------- +// Named Locations (template-embedded) section +// --------------------------------------------------------------------------- +// +// CIPP CA templates persist the named locations referenced by a policy under +// the top-level `LocationInfo` array (one entry per named location). Each +// entry uses Microsoft Graph shape: +// - country: { "@odata.type": "#microsoft.graph.countryNamedLocation", +// displayName, countriesAndRegions: [iso2…], +// includeUnknownCountriesAndRegions, countryLookupMethod } +// - ip: { "@odata.type": "#microsoft.graph.ipNamedLocation", +// displayName, isTrusted, +// ipRanges: [{ "@odata.type": "#microsoft.graph.iPv4CidrRange" +// | "#microsoft.graph.iPv6CidrRange", +// cidrAddress }] } +// +// We can't bind react-hook-form directly to keys containing dots +// (`@odata.type`), so the form uses a sanitised shape with `_type` and +// `_ipRangesText` fields that we map back on save. + +const COUNTRY_TYPE = "#microsoft.graph.countryNamedLocation"; +const IP_TYPE = "#microsoft.graph.ipNamedLocation"; +const IPV4_RANGE_TYPE = "#microsoft.graph.iPv4CidrRange"; +const IPV6_RANGE_TYPE = "#microsoft.graph.iPv6CidrRange"; + +const countryOptions = countryList.map(({ Code, Name }) => ({ value: Code, label: Name })); + +/** Convert one Graph-shape named location to form-shape. */ +function namedLocationToForm(loc) { + if (!loc || typeof loc !== "object") return null; + const type = loc["@odata.type"] === IP_TYPE ? "ip" : "country"; + if (type === "ip") { + const ipRangesText = Array.isArray(loc.ipRanges) + ? loc.ipRanges + .map((r) => r?.cidrAddress) + .filter((v) => typeof v === "string" && v.trim() !== "") + .join("\n") + : ""; + return { + _type: { label: "IP Ranges", value: "ip" }, + displayName: loc.displayName ?? "", + isTrusted: !!loc.isTrusted, + _ipRangesText: ipRangesText, + }; + } + const countries = Array.isArray(loc.countriesAndRegions) ? loc.countriesAndRegions : []; + const lookup = loc.countryLookupMethod ?? "clientIpAddress"; + return { + _type: { label: "Countries / Regions", value: "country" }, + displayName: loc.displayName ?? "", + countriesAndRegions: countries.map((code) => { + const match = countryOptions.find((o) => o.value === code); + return match ?? { label: code, value: code }; + }), + includeUnknownCountriesAndRegions: !!loc.includeUnknownCountriesAndRegions, + countryLookupMethod: { + label: lookup === "authenticatorAppGps" ? "Authenticator app GPS" : "Client IP address", + value: lookup, + }, + }; +} + +/** Unwrap an autoComplete `{label,value}` object to its underlying value. */ +function unwrapAC(v) { + if (v && typeof v === "object" && !Array.isArray(v) && "value" in v) return v.value; + return v; +} + +/** Convert one form-shape named location back to Graph shape. */ +function namedLocationToGraph(item) { + if (!item || !item.displayName || !item.displayName.trim()) return null; + const typeRaw = unwrapAC(item._type); + if (!typeRaw) return null; + const type = typeRaw === "ip" ? "ip" : "country"; + if (type === "ip") { + const lines = String(item._ipRangesText ?? "") + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s !== ""); + if (lines.length === 0) return null; + return { + "@odata.type": IP_TYPE, + displayName: item.displayName.trim(), + isTrusted: !!item.isTrusted, + ipRanges: lines.map((cidr) => ({ + "@odata.type": cidr.includes(":") ? IPV6_RANGE_TYPE : IPV4_RANGE_TYPE, + cidrAddress: cidr, + })), + }; + } + // Country shape — unwrap autoComplete {label,value} objects if present + const countries = Array.isArray(item.countriesAndRegions) + ? item.countriesAndRegions + .map((c) => unwrapAC(c)) + .filter((v) => typeof v === "string" && v !== "") + : []; + if (countries.length === 0) return null; + const lookup = unwrapAC(item.countryLookupMethod); + return { + "@odata.type": COUNTRY_TYPE, + displayName: item.displayName.trim(), + countriesAndRegions: countries, + includeUnknownCountriesAndRegions: !!item.includeUnknownCountriesAndRegions, + countryLookupMethod: lookup || "clientIpAddress", + }; +} + +function NamedLocationsSection({ formControl, disabled }) { + const { fields, append, remove } = useFieldArray({ + control: formControl.control, + name: "LocationInfo", + }); + + return ( + + }> + Named locations defined here are stored inside the template and recreated (or matched by + display name) in the target tenant when the template is deployed. Reference them by name + in the Include Locations / Exclude Locations fields + under Conditions. + + + {fields.length === 0 && ( + + No named locations embedded in this template. + + )} + + {fields.map((field, index) => ( + + + + Named Location #{index + 1} + + + + remove(index)} + disabled={disabled} + aria-label="remove named location" + > + + + + + + + + + + + + + + + {/* IP fields */} + + + + + + + + + + {/* Country fields */} + + + + + + + + + + + + + + ))} + + + + + + ); +} + // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- -const CippCAPolicyBuilder = ({ formControl, existingPolicy, disabled = false }) => { +const CippCAPolicyBuilder = ({ + formControl, + existingPolicy, + disabled = false, + showNamedLocations = false, +}) => { const policySchema = caSchema; // Pre-populate form from existing policy when editing @@ -876,6 +1162,20 @@ const CippCAPolicyBuilder = ({ formControl, existingPolicy, disabled = false }) const path = prefix ? `${prefix}.${key}` : key; + // Special handling for LocationInfo (template-embedded named locations). + // Graph-shape entries contain `@odata.type` keys that react-hook-form + // would interpret as nested paths, so we map them onto a form-friendly + // shape (`_type`, `_ipRangesText`, …) that NamedLocationsSection consumes. + if (key === "LocationInfo" && Array.isArray(value) && !prefix) { + const formItems = value + .map((item) => namedLocationToForm(item)) + .filter((v) => v !== null); + if (formItems.length > 0) { + formControl.setValue("LocationInfo", formItems); + } + return; + } + // Special handling for authenticationStrength — only extract the policy ID, // not the full expanded object (displayName, description, allowedCombinations, etc.) if (key === "authenticationStrength" && typeof value === "object" && !Array.isArray(value)) { @@ -1013,6 +1313,23 @@ const CippCAPolicyBuilder = ({ formControl, existingPolicy, disabled = false }) + + {/* Named Locations (template only) */} + {showNamedLocations && ( + + }> + + + Named Locations + + + + + + + + + )} ); }; @@ -1137,5 +1454,19 @@ export function extractCAPolicyJSON(formValues) { } } + // Post-process: convert template-embedded named locations from form-shape + // back to Graph shape. We read from the raw form values (not `cleaned`) + // because `clean()` strips internal keys prefixed with `_` (e.g. `_type`, + // `_ipRangesText`) that the conversion needs. + delete cleaned.LocationInfo; + if (Array.isArray(formValues?.LocationInfo)) { + const graphLocations = formValues.LocationInfo + .map((item) => namedLocationToGraph(item)) + .filter((v) => v !== null); + if (graphLocations.length > 0) { + cleaned.LocationInfo = graphLocations; + } + } + return cleaned; } diff --git a/src/pages/tenant/conditional/list-template/create.jsx b/src/pages/tenant/conditional/list-template/create.jsx index 1843c369c323..4e94e4327498 100644 --- a/src/pages/tenant/conditional/list-template/create.jsx +++ b/src/pages/tenant/conditional/list-template/create.jsx @@ -28,7 +28,7 @@ const CreateCATemplate = () => { formPageType="Add" > - + ); diff --git a/src/pages/tenant/conditional/list-template/edit.jsx b/src/pages/tenant/conditional/list-template/edit.jsx index 6d82be777062..85cd5bbc6397 100644 --- a/src/pages/tenant/conditional/list-template/edit.jsx +++ b/src/pages/tenant/conditional/list-template/edit.jsx @@ -162,6 +162,7 @@ const EditCATemplate = () => { ) : ( Date: Thu, 4 Jun 2026 23:28:04 +0800 Subject: [PATCH 146/164] Make breadcrumb text and > selectable/copyable --- .../CippComponents/CippBreadcrumbNav.jsx | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/components/CippComponents/CippBreadcrumbNav.jsx b/src/components/CippComponents/CippBreadcrumbNav.jsx index 5ea88f3434cf..a6bef3c6eeef 100644 --- a/src/components/CippComponents/CippBreadcrumbNav.jsx +++ b/src/components/CippComponents/CippBreadcrumbNav.jsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef } from 'react' import { useRouter } from 'next/router' import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip } from '@mui/material' -import { NavigateNext, History, AccountTree } from '@mui/icons-material' +import { History, AccountTree } from '@mui/icons-material' import { nativeMenuItems } from '../../layouts/config' import { useSettings } from '../../hooks/use-settings' @@ -618,9 +618,14 @@ export const CippBreadcrumbNav = () => { } + separator=">" aria-label="page hierarchy" - sx={{ fontSize: '0.875rem', flexGrow: 1 }} + sx={{ + fontSize: '0.875rem', + flexGrow: 1, + userSelect: 'text', + '& .MuiBreadcrumbs-separator': { userSelect: 'text' }, + }} > {breadcrumbs.map((crumb, index) => { const isLast = index === breadcrumbs.length - 1 @@ -648,13 +653,23 @@ export const CippBreadcrumbNav = () => { return ( handleHierarchicalClick(crumb.path, crumb.query)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleHierarchicalClick(crumb.path, crumb.query) + } + }} sx={{ textDecoration: 'none', color: isLast ? 'text.primary' : 'text.secondary', fontWeight: isLast ? 500 : 400, + cursor: 'pointer', + userSelect: 'text', '&:hover': { textDecoration: 'underline', color: 'primary.main', @@ -704,9 +719,14 @@ export const CippBreadcrumbNav = () => { } + separator=">" aria-label="navigation history" - sx={{ fontSize: '0.875rem', flexGrow: 1 }} + sx={{ + fontSize: '0.875rem', + flexGrow: 1, + userSelect: 'text', + '& .MuiBreadcrumbs-separator': { userSelect: 'text' }, + }} > {visibleHistory.map((page, index) => { const isLast = index === visibleHistory.length - 1 @@ -729,12 +749,22 @@ export const CippBreadcrumbNav = () => { return ( handleBreadcrumbClick(actualIndex)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleBreadcrumbClick(actualIndex) + } + }} sx={{ textDecoration: 'none', color: 'text.secondary', + cursor: 'pointer', + userSelect: 'text', '&:hover': { textDecoration: 'underline', color: 'primary.main', From b7c051fa865a06eaf3747be60635740f4837c4ac Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:52:25 +0200 Subject: [PATCH 147/164] Auth changes to use sedndmessage --- .../CippComponents/CIPPM365OAuthButton.jsx | 552 +++++++++--------- src/pages/authredirect.js | 73 ++- 2 files changed, 295 insertions(+), 330 deletions(-) diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx index 0818190ca1cc..cc4086470dab 100644 --- a/src/components/CippComponents/CIPPM365OAuthButton.jsx +++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx @@ -1,29 +1,29 @@ -import { useState, useEffect } from "react"; -import { Alert, Button, Typography, CircularProgress, Box } from "@mui/material"; -import { Microsoft, Login, Refresh } from "@mui/icons-material"; -import { ApiGetCall } from "../../api/ApiCall"; -import { CippCopyToClipBoard } from "./CippCopyToClipboard"; -import { CippApiDialog } from "./CippApiDialog"; +import { useState, useEffect } from 'react' +import { Alert, Button, Typography, CircularProgress, Box } from '@mui/material' +import { Microsoft, Login, Refresh } from '@mui/icons-material' +import { ApiGetCall } from '../../api/ApiCall' +import { CippCopyToClipBoard } from './CippCopyToClipboard' +import { CippApiDialog } from './CippApiDialog' export const CIPPM365OAuthButton = ({ onAuthSuccess, onAuthError, - buttonText = "Login with Microsoft", + buttonText = 'Login with Microsoft', showResults = true, showSuccessAlert = true, - scope = "https://graph.microsoft.com/.default offline_access profile openid", + scope = 'https://graph.microsoft.com/.default offline_access profile openid', useDeviceCode = false, applicationId = null, autoStartDeviceLogon = false, validateServiceAccount = true, promptBeforeAuth = false, }) => { - const [authInProgress, setAuthInProgress] = useState(false); - const [authError, setAuthError] = useState(null); - const [deviceCodeInfo, setDeviceCodeInfo] = useState(null); - const [codeRetrievalInProgress, setCodeRetrievalInProgress] = useState(false); - const [isServiceAccount, setIsServiceAccount] = useState(true); - const [promptDialog, setPromptDialog] = useState({ open: false }); + const [authInProgress, setAuthInProgress] = useState(false) + const [authError, setAuthError] = useState(null) + const [deviceCodeInfo, setDeviceCodeInfo] = useState(null) + const [codeRetrievalInProgress, setCodeRetrievalInProgress] = useState(false) + const [isServiceAccount, setIsServiceAccount] = useState(true) + const [promptDialog, setPromptDialog] = useState({ open: false }) const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null, @@ -32,84 +32,84 @@ export const CIPPM365OAuthButton = ({ username: null, tenantId: null, onmicrosoftDomain: null, - }); + }) const appIdInfo = ApiGetCall({ url: `/api/ExecListAppId`, - queryKey: "listAppId", + queryKey: 'listAppId', waiting: true, - }); + }) const handleCloseError = () => { - setAuthError(null); - }; + setAuthError(null) + } const checkIsServiceAccount = (username) => { - if (!username || !validateServiceAccount) return true; // If no username or validation disabled, don't show warning + if (!username || !validateServiceAccount) return true // If no username or validation disabled, don't show warning - const lowerUsername = username.toLowerCase(); - return lowerUsername.includes("service") || lowerUsername.includes("cipp"); - }; + const lowerUsername = username.toLowerCase() + return lowerUsername.includes('service') || lowerUsername.includes('cipp') + } // Function to retrieve device code const retrieveDeviceCode = async () => { - setCodeRetrievalInProgress(true); - setAuthError(null); + setCodeRetrievalInProgress(true) + setAuthError(null) // Only refetch appId if not already present if (!applicationId && !appIdInfo?.data?.applicationId) { - await appIdInfo.refetch(); + await appIdInfo.refetch() } try { // Get the application ID to use const appId = - applicationId || appIdInfo?.data?.applicationId || "1b730954-1685-4b74-9bfd-dac224a7b894"; // Default to MS Graph Explorer app ID + applicationId || appIdInfo?.data?.applicationId || '1b730954-1685-4b74-9bfd-dac224a7b894' // Default to MS Graph Explorer app ID // Request device code from our API endpoint const deviceCodeResponse = await fetch( `/api/ExecDeviceCodeLogon?operation=getDeviceCode&clientId=${appId}&scope=${encodeURIComponent( - scope, - )}`, - ); - const deviceCodeData = await deviceCodeResponse.json(); + scope + )}` + ) + const deviceCodeData = await deviceCodeResponse.json() if (deviceCodeResponse.ok && deviceCodeData.user_code) { // Store device code info - setDeviceCodeInfo(deviceCodeData); + setDeviceCodeInfo(deviceCodeData) } else { // Error getting device code setAuthError({ - errorCode: deviceCodeData.error || "device_code_error", - errorMessage: deviceCodeData.error_description || "Failed to get device code", + errorCode: deviceCodeData.error || 'device_code_error', + errorMessage: deviceCodeData.error_description || 'Failed to get device code', timestamp: new Date().toISOString(), - }); + }) } } catch (error) { setAuthError({ - errorCode: "device_code_error", - errorMessage: error.message || "An error occurred retrieving device code", + errorCode: 'device_code_error', + errorMessage: error.message || 'An error occurred retrieving device code', timestamp: new Date().toISOString(), - }); + }) } finally { - setCodeRetrievalInProgress(false); + setCodeRetrievalInProgress(false) } - }; + } // Device code authentication function - opens popup and starts polling const handleDeviceCodeAuthentication = async () => { // Only refetch appId if not already present if (!applicationId && !appIdInfo?.data?.applicationId) { - await appIdInfo.refetch(); + await appIdInfo.refetch() } if (!deviceCodeInfo) { // If we don't have a device code yet, retrieve it first - await retrieveDeviceCode(); - return; + await retrieveDeviceCode() + return } - setAuthInProgress(true); + setAuthInProgress(true) setTokens({ accessToken: null, refreshToken: null, @@ -118,131 +118,131 @@ export const CIPPM365OAuthButton = ({ username: null, tenantId: null, onmicrosoftDomain: null, - }); + }) try { // Get the application ID to use - refetch already happened at the start of this function const appId = - applicationId || appIdInfo?.data?.applicationId || "1b730954-1685-4b74-9bfd-dac224a7b894"; // Default to MS Graph Explorer app ID + applicationId || appIdInfo?.data?.applicationId || '1b730954-1685-4b74-9bfd-dac224a7b894' // Default to MS Graph Explorer app ID // Open popup to device login page - const width = 500; - const height = 600; - const left = window.screen.width / 2 - width / 2; - const top = window.screen.height / 2 - height / 2; + const width = 500 + const height = 600 + const left = window.screen.width / 2 - width / 2 + const top = window.screen.height / 2 - height / 2 const popup = window.open( - "https://microsoft.com/devicelogin", - "deviceLoginPopup", - `width=${width},height=${height},left=${left},top=${top}`, - ); + 'https://microsoft.com/devicelogin', + 'deviceLoginPopup', + `width=${width},height=${height},left=${left},top=${top}` + ) // Start polling for token - const pollInterval = deviceCodeInfo.interval || 5; - const expiresIn = deviceCodeInfo.expires_in || 900; - const startTime = Date.now(); + const pollInterval = deviceCodeInfo.interval || 5 + const expiresIn = deviceCodeInfo.expires_in || 900 + const startTime = Date.now() const pollForToken = async () => { // Check if we've exceeded the expiration time if (Date.now() - startTime >= expiresIn * 1000) { if (popup && !popup.closed) { - popup.close(); + popup.close() } setAuthError({ - errorCode: "timeout", - errorMessage: "Device code authentication timed out", + errorCode: 'timeout', + errorMessage: 'Device code authentication timed out', timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); - return; + }) + setAuthInProgress(false) + return } try { // Poll for token using our API endpoint const tokenResponse = await fetch( - `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}`, - ); - const tokenData = await tokenResponse.json(); + `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}` + ) + const tokenData = await tokenResponse.json() - if (tokenResponse.ok && tokenData.status === "success") { + if (tokenResponse.ok && tokenData.status === 'success') { // Successfully got token if (popup && !popup.closed) { - popup.close(); + popup.close() } - handleTokenResponse(tokenData); + handleTokenResponse(tokenData) } else if ( - tokenData.error === "authorization_pending" || - tokenData.status === "pending" + tokenData.error === 'authorization_pending' || + tokenData.status === 'pending' ) { // User hasn't completed authentication yet, continue polling - setTimeout(pollForToken, pollInterval * 1000); - } else if (tokenData.error === "slow_down") { + setTimeout(pollForToken, pollInterval * 1000) + } else if (tokenData.error === 'slow_down') { // Server asking us to slow down polling - setTimeout(pollForToken, (pollInterval + 5) * 1000); + setTimeout(pollForToken, (pollInterval + 5) * 1000) } else { // Other error if (popup && !popup.closed) { - popup.close(); + popup.close() } setAuthError({ - errorCode: tokenData.error || "token_error", - errorMessage: tokenData.error_description || "Failed to get token", + errorCode: tokenData.error || 'token_error', + errorMessage: tokenData.error_description || 'Failed to get token', timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); + }) + setAuthInProgress(false) } } catch (error) { - setTimeout(pollForToken, pollInterval * 1000); + setTimeout(pollForToken, pollInterval * 1000) } - }; + } // Start polling - setTimeout(pollForToken, pollInterval * 1000); + setTimeout(pollForToken, pollInterval * 1000) } catch (error) { setAuthError({ - errorCode: "device_code_error", - errorMessage: error.message || "An error occurred during device code authentication", + errorCode: 'device_code_error', + errorMessage: error.message || 'An error occurred during device code authentication', timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); + }) + setAuthInProgress(false) } - }; + } // Process token response (common for both auth methods) const handleTokenResponse = (tokenData) => { // Extract token information - const accessTokenExpiresOn = new Date(Date.now() + tokenData.expires_in * 1000); + const accessTokenExpiresOn = new Date(Date.now() + tokenData.expires_in * 1000) // Refresh tokens typically last for 90 days, but this can vary - const refreshTokenExpiresOn = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); + const refreshTokenExpiresOn = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // Extract information from ID token if available - let username = "unknown user"; - let tenantId = "unknown tenant"; - let onmicrosoftDomain = null; + let username = 'unknown user' + let tenantId = 'unknown tenant' + let onmicrosoftDomain = null if (tokenData.id_token) { try { - const idTokenPayload = JSON.parse(atob(tokenData.id_token.split(".")[1])); + const idTokenPayload = JSON.parse(atob(tokenData.id_token.split('.')[1])) username = idTokenPayload.preferred_username || idTokenPayload.email || idTokenPayload.upn || idTokenPayload.name || - "unknown user"; + 'unknown user' if (idTokenPayload.tid) { - tenantId = idTokenPayload.tid; + tenantId = idTokenPayload.tid } - if (username && username.includes("@") && username.includes(".onmicrosoft.com")) { - onmicrosoftDomain = username.split("@")[1]; + if (username && username.includes('@') && username.includes('.onmicrosoft.com')) { + onmicrosoftDomain = username.split('@')[1] } else if (idTokenPayload.iss) { - const issuerMatch = idTokenPayload.iss.match(/https:\/\/sts\.windows\.net\/([^/]+)\//); + const issuerMatch = idTokenPayload.iss.match(/https:\/\/sts\.windows\.net\/([^/]+)\//) if (issuerMatch && issuerMatch[1]) { } } - setIsServiceAccount(checkIsServiceAccount(username)); + setIsServiceAccount(checkIsServiceAccount(username)) } catch (error) {} } @@ -255,25 +255,25 @@ export const CIPPM365OAuthButton = ({ username: username, tenantId: tenantId, onmicrosoftDomain: onmicrosoftDomain, - }; + } - setTokens(tokenResult); - setDeviceCodeInfo(null); + setTokens(tokenResult) + setDeviceCodeInfo(null) - if (onAuthSuccess) onAuthSuccess(tokenResult); + if (onAuthSuccess) onAuthSuccess(tokenResult) // Update UI state - setAuthInProgress(false); - setIsServiceAccount(checkIsServiceAccount(username)); - }; + setAuthInProgress(false) + setIsServiceAccount(checkIsServiceAccount(username)) + } // MSAL-like authentication function const handleMsalAuthentication = async (retryCount = 0) => { - const maxRetries = 3; + const maxRetries = 3 // Clear previous authentication state when starting a new authentication - setAuthInProgress(true); - setAuthError(null); + setAuthInProgress(true) + setAuthError(null) setTokens({ accessToken: null, refreshToken: null, @@ -282,15 +282,15 @@ export const CIPPM365OAuthButton = ({ username: null, tenantId: null, onmicrosoftDomain: null, - }); + }) // Only refetch app ID if not already present if (!applicationId && !appIdInfo?.data?.applicationId) { - await appIdInfo.refetch(); + await appIdInfo.refetch() } // Get the application ID to use - const appId = applicationId || appIdInfo?.data?.applicationId; + const appId = applicationId || appIdInfo?.data?.applicationId // Generate MSAL-like authentication parameters const msalConfig = { @@ -299,23 +299,23 @@ export const CIPPM365OAuthButton = ({ authority: `https://login.microsoftonline.com/common`, redirectUri: `${window.location.origin}/authredirect`, }, - }; + } // Define the request object similar to MSAL const loginRequest = { scopes: [scope], - }; + } // Generate PKCE code verifier and challenge const generateCodeVerifier = () => { - const array = new Uint8Array(32); - window.crypto.getRandomValues(array); - return Array.from(array, (byte) => ("0" + (byte & 0xff).toString(16)).slice(-2)).join(""); - }; - - const codeVerifier = generateCodeVerifier(); - const codeChallenge = codeVerifier; - const state = Math.random().toString(36).substring(2, 15); + const array = new Uint8Array(32) + window.crypto.getRandomValues(array) + return Array.from(array, (byte) => ('0' + (byte & 0xff).toString(16)).slice(-2)).join('') + } + + const codeVerifier = generateCodeVerifier() + const codeChallenge = codeVerifier + const state = Math.random().toString(36).substring(2, 15) const authUrl = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` + `client_id=${appId}` + @@ -325,96 +325,96 @@ export const CIPPM365OAuthButton = ({ `&code_challenge=${codeChallenge}` + `&code_challenge_method=plain` + `&state=${state}` + - `&prompt=select_account`; + `&prompt=select_account` // Open popup for authentication - const width = 500; - const height = 600; - const left = window.screen.width / 2 - width / 2; - const top = window.screen.height / 2 - height / 2; + const width = 500 + const height = 600 + const left = window.screen.width / 2 - width / 2 + const top = window.screen.height / 2 - height / 2 const popup = window.open( authUrl, - "msalAuthPopup", - `width=${width},height=${height},left=${left},top=${top}`, - ); + 'msalAuthPopup', + `width=${width},height=${height},left=${left},top=${top}` + ) // Function to actually exchange the authorization code for tokens const handleAuthorizationCode = async (code, receivedState) => { // Verify the state parameter matches what we sent (security check) if (receivedState !== state) { - const errorMessage = "State mismatch in auth response - possible CSRF attack"; + const errorMessage = 'State mismatch in auth response - possible CSRF attack' const error = { - errorCode: "state_mismatch", + errorCode: 'state_mismatch', errorMessage: errorMessage, timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); - setAuthInProgress(false); - return; + } + setAuthError(error) + if (onAuthError) onAuthError(error) + setAuthInProgress(false) + return } try { // Prepare the token request const tokenRequest = { - grant_type: "authorization_code", + grant_type: 'authorization_code', client_id: appId, code: code, redirect_uri: `${window.location.origin}/authredirect`, code_verifier: codeVerifier, - }; + } // Make the token request through our API proxy to avoid origin header issues // Retry logic for AADSTS650051 (service principal already exists) - let retryCount = 0; - const maxRetries = 3; - let tokenResponse; - let tokenData; + let retryCount = 0 + const maxRetries = 3 + let tokenResponse + let tokenData while (retryCount <= maxRetries) { tokenResponse = await fetch(`/api/ExecTokenExchange`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ tokenRequest, - tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', tenantId: appId, // Pass the tenant ID to retrieve the correct client secret }), - }); + }) // Parse the token response - tokenData = await tokenResponse.json(); + tokenData = await tokenResponse.json() // Check if it's the AADSTS650051 error (service principal already exists) if ( - tokenData.error === "invalid_client" && - tokenData.error_description?.includes("AADSTS650051") + tokenData.error === 'invalid_client' && + tokenData.error_description?.includes('AADSTS650051') ) { - retryCount++; + retryCount++ if (retryCount <= maxRetries) { // Wait before retrying (exponential backoff) - await new Promise((resolve) => setTimeout(resolve, 2000 * retryCount)); - continue; + await new Promise((resolve) => setTimeout(resolve, 2000 * retryCount)) + continue } } // If no error or different error, break out of retry loop - break; + break } // Check if the response contains an error if (tokenData.error) { const error = { - errorCode: tokenData.error || "token_error", + errorCode: tokenData.error || 'token_error', errorMessage: - tokenData.error_description || "Failed to exchange authorization code for tokens", + tokenData.error_description || 'Failed to exchange authorization code for tokens', timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); - setAuthInProgress(false); - return; + } + setAuthError(error) + if (onAuthError) onAuthError(error) + setAuthInProgress(false) + return } if (tokenResponse.ok) { @@ -422,13 +422,13 @@ export const CIPPM365OAuthButton = ({ if (tokenData.refresh_token) { try { // Extract tid from access_token jwt base64 - const accessTokenParts = tokenData.access_token.split("."); - const accessTokenPayload = JSON.parse(atob(accessTokenParts[1] || "")); - tokenData.tid = accessTokenPayload.tid; + const accessTokenParts = tokenData.access_token.split('.') + const accessTokenPayload = JSON.parse(atob(accessTokenParts[1] || '')) + tokenData.tid = accessTokenPayload.tid const refreshResponse = await fetch(`/api/ExecUpdateRefreshToken`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ tenantId: tokenData.tid, @@ -436,68 +436,100 @@ export const CIPPM365OAuthButton = ({ tenantMode: tokenData.tenantMode, allowPartnerTenantManagement: tokenData.allowPartnerTenantManagement, }), - }); + }) if (!refreshResponse.ok) { - console.warn("Failed to store refresh token, but continuing with authentication"); + console.warn('Failed to store refresh token, but continuing with authentication') } else { // Invalidate the listAppId and tenants-table queryKeys to refresh data - appIdInfo.refetch(); + appIdInfo.refetch() } } catch (error) { - console.error("Failed to store refresh token:", error); + console.error('Failed to store refresh token:', error) } } - handleTokenResponse(tokenData); + handleTokenResponse(tokenData) } else { // Handle token error - display in error box instead of throwing const error = { - errorCode: tokenData.error || "token_error", + errorCode: tokenData.error || 'token_error', errorMessage: - tokenData.error_description || "Failed to exchange authorization code for tokens", + tokenData.error_description || 'Failed to exchange authorization code for tokens', timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); + } + setAuthError(error) + if (onAuthError) onAuthError(error) } } catch (error) { const errorObj = { - errorCode: "token_exchange_error", - errorMessage: error.message || "Failed to exchange authorization code for tokens", + errorCode: 'token_exchange_error', + errorMessage: error.message || 'Failed to exchange authorization code for tokens', timestamp: new Date().toISOString(), - }; - setAuthError(errorObj); - if (onAuthError) onAuthError(errorObj); + } + setAuthError(errorObj) + if (onAuthError) onAuthError(errorObj) } finally { // Close the popup window if it's still open if (popup && !popup.closed) { - popup.close(); + popup.close() } // Update UI state - setAuthInProgress(false); + setAuthInProgress(false) } - }; + } - // Monitor for the redirect with the authorization code - // This is what MSAL does internally - const checkPopupLocation = setInterval(() => { - if (!popup || popup.closed) { - clearInterval(checkPopupLocation); + // Listen for postMessage from the authredirect page + let codeReceived = false + + const handleMessage = (event) => { + if (event.origin !== window.location.origin) return + + if (event.data?.type === 'auth_code') { + codeReceived = true + cleanup() + handleAuthorizationCode(event.data.code, event.data.state) + } else if (event.data?.type === 'auth_error') { + codeReceived = true + cleanup() + + // Check if it's the AADSTS650051 error (service principal already exists during consent) + if ( + event.data.error === 'invalid_client' && + event.data.errorDescription?.includes('AADSTS650051') && + retryCount < maxRetries + ) { + if (popup && !popup.closed) popup.close() + setAuthInProgress(false) + setTimeout(() => handleMsalAuthentication(retryCount + 1), 2000 * (retryCount + 1)) + return + } + + const error = { + errorCode: event.data.error || 'auth_error', + errorMessage: event.data.errorDescription || 'Unknown authentication error', + timestamp: new Date().toISOString(), + } + setAuthError(error) + if (onAuthError) onAuthError(error) + if (popup && !popup.closed) popup.close() + setAuthInProgress(false) + } + } - // If authentication is still in progress when popup closes, it's an error - if (authInProgress) { - const errorMessage = "Authentication was cancelled. Please try again."; + // Check if popup was closed before we received the code + const popupClosedCheck = setInterval(() => { + if (!popup || popup.closed) { + if (!codeReceived) { + cleanup() const error = { - errorCode: "user_cancelled", - errorMessage: errorMessage, + errorCode: 'user_cancelled', + errorMessage: 'Authentication was cancelled. Please try again.', timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); - - // Ensure we're not showing any previous success state + } + setAuthError(error) + if (onAuthError) onAuthError(error) setTokens({ accessToken: null, refreshToken: null, @@ -506,79 +538,19 @@ export const CIPPM365OAuthButton = ({ username: null, tenantId: null, onmicrosoftDomain: null, - }); + }) + setAuthInProgress(false) } - - setAuthInProgress(false); - return; } + }, 500) - try { - // Try to access the popup location to check for the authorization code - const currentUrl = popup.location.href; - - // Check if the URL contains a code parameter (authorization code) - if (currentUrl.includes("code=") && currentUrl.includes("state=")) { - clearInterval(checkPopupLocation); - // Parse the URL to extract the code and state - const urlParams = new URLSearchParams(popup.location.search); - const code = urlParams.get("code"); - const receivedState = urlParams.get("state"); - - // Process the authorization code - handleAuthorizationCode(code, receivedState); - } - - // Check for error in the URL - if (currentUrl.includes("error=")) { - clearInterval(checkPopupLocation); - // Parse the URL to extract the error details - const urlParams = new URLSearchParams(popup.location.search); - const errorCode = urlParams.get("error"); - const errorDescription = urlParams.get("error_description"); - - // Check if it's the AADSTS650051 error (service principal already exists during consent) - if ( - errorCode === "invalid_client" && - errorDescription?.includes("AADSTS650051") && - retryCount < maxRetries - ) { - // Close the popup - popup.close(); - setAuthInProgress(false); - - // Wait before retrying (exponential backoff) - setTimeout( - () => { - handleMsalAuthentication(retryCount + 1); - }, - 2000 * (retryCount + 1), - ); - return; - } - - // Set the error state for non-retryable errors - const error = { - errorCode: errorCode, - errorMessage: errorDescription || "Unknown authentication error", - timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); - - // Close the popup - popup.close(); - setAuthInProgress(false); - } - } catch (error) { - // This will throw an error when the popup is on a different domain - // due to cross-origin restrictions, which is normal during auth flow - // Just continue monitoring - } - }, 500); + const cleanup = () => { + window.removeEventListener('message', handleMessage) + clearInterval(popupClosedCheck) + } - // Also monitor for popup closing as a fallback - }; + window.addEventListener('message', handleMessage) + } // Auto-start device code retrieval if requested useEffect(() => { @@ -590,7 +562,7 @@ export const CIPPM365OAuthButton = ({ !tokens.accessToken && appIdInfo?.data ) { - retrieveDeviceCode(); + retrieveDeviceCode() } }, [ useDeviceCode, @@ -599,7 +571,7 @@ export const CIPPM365OAuthButton = ({ deviceCodeInfo, tokens.accessToken, appIdInfo?.data, - ]); + ]) return (
@@ -607,7 +579,7 @@ export const CIPPM365OAuthButton = ({ !appIdInfo.isLoading && appIdInfo?.data?.applicationId && // Only check if applicationId is present in data !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - appIdInfo?.data?.applicationId, + appIdInfo?.data?.applicationId ) && ( The Application ID is not valid. Please check your configuration. @@ -622,14 +594,14 @@ export const CIPPM365OAuthButton = ({ {authInProgress ? ( <> - When asked to log onto an account, please use a{" "} - CIPP Service Account. Enter this code to authenticate:{" "} + When asked to log onto an account, please use a{' '} + CIPP Service Account. Enter this code to authenticate:{' '} ) : ( <> Click the button below to authenticate. When asked to log onto an account, please use a CIPP Service Account. You will need to enter this - code:{" "} + code:{' '} )} @@ -637,13 +609,13 @@ export const CIPPM365OAuthButton = ({ {authInProgress ? ( <> - If the popup was blocked or you closed it, you can also go to{" "} + If the popup was blocked or you closed it, you can also go to{' '} microsoft.com/devicelogin manually and enter the code shown above. ) : ( <> - When you click the button below, a popup will open to{" "} + When you click the button below, a popup will open to{' '} microsoft.com/devicelogin where you'll enter this code. )} @@ -665,7 +637,7 @@ export const CIPPM365OAuthButton = ({ Tenant ID: {tokens.tenantId} {tokens.onmicrosoftDomain && ( <> - {" "} + {' '} | Domain: {tokens.onmicrosoftDomain} )} @@ -711,21 +683,21 @@ export const CIPPM365OAuthButton = ({ {promptBeforeAuth !== false && ( setPromptDialog({ open: false }), }} api={{ - type: "POST", + type: 'POST', confirmText: promptBeforeAuth, noConfirm: false, customFunction: () => { - setPromptDialog({ open: false }); + setPromptDialog({ open: false }) const authFunction = useDeviceCode ? handleDeviceCodeAuthentication - : handleMsalAuthentication; - authFunction(); + : handleMsalAuthentication + authFunction() }, }} fields={[]} @@ -740,17 +712,17 @@ export const CIPPM365OAuthButton = ({ codeRetrievalInProgress || (!applicationId && !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - appIdInfo?.data?.applicationId, + appIdInfo?.data?.applicationId )) } onClick={() => { if (promptBeforeAuth !== false) { - setPromptDialog({ open: true }); + setPromptDialog({ open: true }) } else { const authFunction = useDeviceCode ? handleDeviceCodeAuthentication - : handleMsalAuthentication; - authFunction(); + : handleMsalAuthentication + authFunction() } }} color="primary" @@ -765,11 +737,11 @@ export const CIPPM365OAuthButton = ({ } > {authInProgress || codeRetrievalInProgress - ? "Authenticating..." + ? 'Authenticating...' : deviceCodeInfo && useDeviceCode - ? "Authenticate with Code" + ? 'Authenticate with Code' : buttonText}
- ); -}; + ) +} diff --git a/src/pages/authredirect.js b/src/pages/authredirect.js index ea8edfe0937d..a5f4186ea206 100644 --- a/src/pages/authredirect.js +++ b/src/pages/authredirect.js @@ -1,47 +1,40 @@ -import { Box, Container, Stack } from "@mui/material"; -import { Grid } from "@mui/system"; +import { useEffect } from "react"; import Head from "next/head"; -import { CippImageCard } from "../components/CippCards/CippImageCard.jsx"; -import { Layout as DashboardLayout } from "../layouts/index.js"; -const Page = () => ( - <> - +const Page = () => { + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + const state = params.get("state"); + const error = params.get("error"); + const errorDescription = params.get("error_description"); + + if (window.opener) { + if (code && state) { + window.opener.postMessage( + { type: "auth_code", code, state }, + window.location.origin + ); + } else if (error) { + window.opener.postMessage( + { type: "auth_error", error, errorDescription }, + window.location.origin + ); + } + window.close(); + } + }, []); + + return ( + <> Authentication complete - - - - - - - - - - - - - -); +
+

Authentication complete. This window will close automatically.

+
+ + ); +}; export default Page; From 67039061cb901709c0cd35b2baa1fca1026cdc1e Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:52:28 +0200 Subject: [PATCH 148/164] Auth changes to use sedndmessage --- src/pages/authredirect.js | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/pages/authredirect.js b/src/pages/authredirect.js index a5f4186ea206..8acf21eee84e 100644 --- a/src/pages/authredirect.js +++ b/src/pages/authredirect.js @@ -1,40 +1,39 @@ -import { useEffect } from "react"; -import Head from "next/head"; +import { useEffect } from 'react' +import Head from 'next/head' const Page = () => { useEffect(() => { - const params = new URLSearchParams(window.location.search); - const code = params.get("code"); - const state = params.get("state"); - const error = params.get("error"); - const errorDescription = params.get("error_description"); + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + const state = params.get('state') + const error = params.get('error') + const errorDescription = params.get('error_description') if (window.opener) { if (code && state) { - window.opener.postMessage( - { type: "auth_code", code, state }, - window.location.origin - ); + window.opener.postMessage({ type: 'auth_code', code, state }, window.location.origin) } else if (error) { window.opener.postMessage( - { type: "auth_error", error, errorDescription }, + { type: 'auth_error', error, errorDescription }, window.location.origin - ); + ) } - window.close(); + window.close() } - }, []); + }, []) return ( <> Authentication complete -
+

Authentication complete. This window will close automatically.

- ); -}; + ) +} -export default Page; +export default Page From 5b09ef3264fef015af58826d1977c75e1960190f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 4 Jun 2026 16:55:52 -0400 Subject: [PATCH 149/164] fix: add popup grace period --- .../CippComponents/CIPPM365OAuthButton.jsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx index cc4086470dab..61e240579b5a 100644 --- a/src/components/CippComponents/CIPPM365OAuthButton.jsx +++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx @@ -327,18 +327,31 @@ export const CIPPM365OAuthButton = ({ `&state=${state}` + `&prompt=select_account` - // Open popup for authentication + // Open a blank popup first, then navigate it. This keeps the window reference stable and + // avoids treating slow Microsoft page loads as an immediate user cancellation. const width = 500 const height = 600 const left = window.screen.width / 2 - width / 2 const top = window.screen.height / 2 - height / 2 const popup = window.open( - authUrl, + 'about:blank', 'msalAuthPopup', `width=${width},height=${height},left=${left},top=${top}` ) + if (!popup) { + setAuthError({ + errorCode: 'popup_blocked', + errorMessage: 'Authentication popup was blocked by the browser. Please allow popups and try again.', + timestamp: new Date().toISOString(), + }) + setAuthInProgress(false) + return + } + + popup.location.href = authUrl + // Function to actually exchange the authorization code for tokens const handleAuthorizationCode = async (code, receivedState) => { // Verify the state parameter matches what we sent (security check) @@ -482,6 +495,8 @@ export const CIPPM365OAuthButton = ({ // Listen for postMessage from the authredirect page let codeReceived = false + const popupGracePeriodMs = 5000 + const popupOpenedAt = Date.now() const handleMessage = (event) => { if (event.origin !== window.location.origin) return @@ -520,6 +535,10 @@ export const CIPPM365OAuthButton = ({ // Check if popup was closed before we received the code const popupClosedCheck = setInterval(() => { + if (Date.now() - popupOpenedAt < popupGracePeriodMs) { + return + } + if (!popup || popup.closed) { if (!codeReceived) { cleanup() From ffefbbb337754727297ecec9be79e1e7b16a4b32 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:24:15 +0200 Subject: [PATCH 150/164] Use broadcast channel --- .../CippComponents/CIPPM365OAuthButton.jsx | 75 +++++++------------ src/pages/authredirect.js | 17 ++--- 2 files changed, 34 insertions(+), 58 deletions(-) diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx index cc4086470dab..5342b51d931c 100644 --- a/src/components/CippComponents/CIPPM365OAuthButton.jsx +++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx @@ -333,11 +333,7 @@ export const CIPPM365OAuthButton = ({ const left = window.screen.width / 2 - width / 2 const top = window.screen.height / 2 - height / 2 - const popup = window.open( - authUrl, - 'msalAuthPopup', - `width=${width},height=${height},left=${left},top=${top}` - ) + window.open(authUrl, 'msalAuthPopup', `width=${width},height=${height},left=${left},top=${top}`) // Function to actually exchange the authorization code for tokens const handleAuthorizationCode = async (code, receivedState) => { @@ -470,28 +466,41 @@ export const CIPPM365OAuthButton = ({ setAuthError(errorObj) if (onAuthError) onAuthError(errorObj) } finally { - // Close the popup window if it's still open - if (popup && !popup.closed) { - popup.close() - } - // Update UI state setAuthInProgress(false) } } - // Listen for postMessage from the authredirect page - let codeReceived = false + // Listen for auth result via BroadcastChannel (works regardless of COOP) + const channel = new BroadcastChannel('cipp_auth') - const handleMessage = (event) => { - if (event.origin !== window.location.origin) return + const authTimeout = setTimeout(() => { + // If no response after 10 minutes, treat as cancelled + cleanup() + const error = { + errorCode: 'timeout', + errorMessage: 'Authentication timed out. Please try again.', + timestamp: new Date().toISOString(), + } + setAuthError(error) + if (onAuthError) onAuthError(error) + setTokens({ + accessToken: null, + refreshToken: null, + accessTokenExpiresOn: null, + refreshTokenExpiresOn: null, + username: null, + tenantId: null, + onmicrosoftDomain: null, + }) + setAuthInProgress(false) + }, 600000) + channel.onmessage = (event) => { if (event.data?.type === 'auth_code') { - codeReceived = true cleanup() handleAuthorizationCode(event.data.code, event.data.state) } else if (event.data?.type === 'auth_error') { - codeReceived = true cleanup() // Check if it's the AADSTS650051 error (service principal already exists during consent) @@ -500,7 +509,6 @@ export const CIPPM365OAuthButton = ({ event.data.errorDescription?.includes('AADSTS650051') && retryCount < maxRetries ) { - if (popup && !popup.closed) popup.close() setAuthInProgress(false) setTimeout(() => handleMsalAuthentication(retryCount + 1), 2000 * (retryCount + 1)) return @@ -513,43 +521,14 @@ export const CIPPM365OAuthButton = ({ } setAuthError(error) if (onAuthError) onAuthError(error) - if (popup && !popup.closed) popup.close() setAuthInProgress(false) } } - // Check if popup was closed before we received the code - const popupClosedCheck = setInterval(() => { - if (!popup || popup.closed) { - if (!codeReceived) { - cleanup() - const error = { - errorCode: 'user_cancelled', - errorMessage: 'Authentication was cancelled. Please try again.', - timestamp: new Date().toISOString(), - } - setAuthError(error) - if (onAuthError) onAuthError(error) - setTokens({ - accessToken: null, - refreshToken: null, - accessTokenExpiresOn: null, - refreshTokenExpiresOn: null, - username: null, - tenantId: null, - onmicrosoftDomain: null, - }) - setAuthInProgress(false) - } - } - }, 500) - const cleanup = () => { - window.removeEventListener('message', handleMessage) - clearInterval(popupClosedCheck) + channel.close() + clearTimeout(authTimeout) } - - window.addEventListener('message', handleMessage) } // Auto-start device code retrieval if requested diff --git a/src/pages/authredirect.js b/src/pages/authredirect.js index 8acf21eee84e..7295c7f1f9c9 100644 --- a/src/pages/authredirect.js +++ b/src/pages/authredirect.js @@ -9,17 +9,14 @@ const Page = () => { const error = params.get('error') const errorDescription = params.get('error_description') - if (window.opener) { - if (code && state) { - window.opener.postMessage({ type: 'auth_code', code, state }, window.location.origin) - } else if (error) { - window.opener.postMessage( - { type: 'auth_error', error, errorDescription }, - window.location.origin - ) - } - window.close() + const channel = new BroadcastChannel('cipp_auth') + if (code && state) { + channel.postMessage({ type: 'auth_code', code, state }) + } else if (error) { + channel.postMessage({ type: 'auth_error', error, errorDescription }) } + channel.close() + window.close() }, []) return ( From 0a8252e3a2a954a734f58637179e7eed91dcb16c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 4 Jun 2026 20:15:21 -0400 Subject: [PATCH 151/164] fix: version encoding --- src/components/CippSettings/CippVersionProperties.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CippSettings/CippVersionProperties.jsx b/src/components/CippSettings/CippVersionProperties.jsx index 4b19e1d9e328..97542aa3ebbc 100644 --- a/src/components/CippSettings/CippVersionProperties.jsx +++ b/src/components/CippSettings/CippVersionProperties.jsx @@ -11,7 +11,7 @@ const CippVersionProperties = () => { }); const cippVersion = ApiGetCall({ - url: `/api/GetVersion?LocalVersion=${version?.data?.version}`, + url: `/api/GetVersion?LocalVersion=${encodeURIComponent(version?.data?.version ?? "")}`, queryKey: "CippVersion", waiting: false, }); From c8d61c0757853359cde98f9a00e6e3ba6fe6e98e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 4 Jun 2026 23:13:18 -0400 Subject: [PATCH 152/164] fix: JIT admin, remove creatable on autocomplete --- .../identity/administration/jit-admin/add.jsx | 367 +++++++++--------- 1 file changed, 184 insertions(+), 183 deletions(-) diff --git a/src/pages/identity/administration/jit-admin/add.jsx b/src/pages/identity/administration/jit-admin/add.jsx index d99a1b6518c1..478f7ca51bfc 100644 --- a/src/pages/identity/administration/jit-admin/add.jsx +++ b/src/pages/identity/administration/jit-admin/add.jsx @@ -1,112 +1,111 @@ -import { Box, Divider } from "@mui/material"; -import { Grid } from "@mui/system"; -import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippFormTenantSelector } from "../../../../components/CippComponents/CippFormTenantSelector"; -import { useForm, useWatch } from "react-hook-form"; -import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; -import { CippFormCondition } from "../../../../components/CippComponents/CippFormCondition"; -import gdaproles from "../../../../data/GDAPRoles.json"; -import countryList from "../../../../data/countryList.json"; -import { CippFormDomainSelector } from "../../../../components/CippComponents/CippFormDomainSelector"; -import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector"; -import { CippFormGroupSelector } from "../../../../components/CippComponents/CippFormGroupSelector"; -import { ApiGetCall } from "../../../../api/ApiCall"; -import { useEffect, useState } from "react"; +import { Box, Divider } from '@mui/material' +import { Grid } from '@mui/system' +import CippFormPage from '../../../../components/CippFormPages/CippFormPage' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippFormTenantSelector } from '../../../../components/CippComponents/CippFormTenantSelector' +import { useForm, useWatch } from 'react-hook-form' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { CippFormCondition } from '../../../../components/CippComponents/CippFormCondition' +import gdaproles from '../../../../data/GDAPRoles.json' +import countryList from '../../../../data/countryList.json' +import { CippFormDomainSelector } from '../../../../components/CippComponents/CippFormDomainSelector' +import { CippFormUserSelector } from '../../../../components/CippComponents/CippFormUserSelector' +import { CippFormGroupSelector } from '../../../../components/CippComponents/CippFormGroupSelector' +import { ApiGetCall } from '../../../../api/ApiCall' +import { useEffect, useState } from 'react' const Page = () => { - const formControl = useForm({ mode: "onChange" }); - const selectedTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); - const [selectedTemplate, setSelectedTemplate] = useState(null); + const formControl = useForm({ mode: 'onChange' }) + const selectedTenant = useWatch({ control: formControl.control, name: 'tenantFilter' }) + const [selectedTemplate, setSelectedTemplate] = useState(null) const jitAdminTemplates = ApiGetCall({ url: selectedTenant ? `/api/ListJITAdminTemplates?TenantFilter=${selectedTenant.value}` : undefined, - queryKey: selectedTenant ? `JITAdminTemplates-${selectedTenant.value}` : "JITAdminTemplates", + queryKey: selectedTenant ? `JITAdminTemplates-${selectedTenant.value}` : 'JITAdminTemplates', refetchOnMount: false, refetchOnReconnect: false, waiting: !!selectedTenant, - }); + }) - const watcher = useWatch({ control: formControl.control }); - const useTAP = useWatch({ control: formControl.control, name: "UseTAP" }); - const startDate = useWatch({ control: formControl.control, name: "startDate" }); - const endDate = useWatch({ control: formControl.control, name: "endDate" }); + const watcher = useWatch({ control: formControl.control }) + const useTAP = useWatch({ control: formControl.control, name: 'UseTAP' }) + const startDate = useWatch({ control: formControl.control, name: 'startDate' }) + const endDate = useWatch({ control: formControl.control, name: 'endDate' }) const tapLifetimeInMinutes = useWatch({ control: formControl.control, - name: "tapLifetimeInMinutes", - }); + name: 'tapLifetimeInMinutes', + }) const tapPolicy = ApiGetCall({ - url: selectedTenant - ? `/api/ListGraphRequest` - : undefined, + url: selectedTenant ? `/api/ListGraphRequest` : undefined, data: { - Endpoint: "policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass", + Endpoint: + 'policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass', tenantFilter: selectedTenant?.value, }, - queryKey: selectedTenant ? `TAPPolicy-${selectedTenant.value}` : "TAPPolicy", + queryKey: selectedTenant ? `TAPPolicy-${selectedTenant.value}` : 'TAPPolicy', waiting: !!selectedTenant, - }); - const tapEnabled = tapPolicy.isSuccess && tapPolicy.data?.Results?.[0]?.state === "enabled"; - const useRoles = useWatch({ control: formControl.control, name: "useRoles" }); - const useGroups = useWatch({ control: formControl.control, name: "useGroups" }); + }) + const tapEnabled = tapPolicy.isSuccess && tapPolicy.data?.Results?.[0]?.state === 'enabled' + const useRoles = useWatch({ control: formControl.control, name: 'useRoles' }) + const useGroups = useWatch({ control: formControl.control, name: 'useGroups' }) useEffect(() => { if (!useTAP || !startDate || !endDate) { - formControl.setValue("tapLifetimeInMinutes", null); - return; + formControl.setValue('tapLifetimeInMinutes', null) + return } - const requestedMinutes = Math.max(1, Math.round((endDate - startDate) / 60)); - const tapPolicyConfig = tapPolicy.data?.Results?.[0]; - const policyMax = tapPolicyConfig?.maximumLifetimeInMinutes ?? 1440; - const policyMin = Math.min(tapPolicyConfig?.minimumLifetimeInMinutes ?? 1, policyMax); + const requestedMinutes = Math.max(1, Math.round((endDate - startDate) / 60)) + const tapPolicyConfig = tapPolicy.data?.Results?.[0] + const policyMax = tapPolicyConfig?.maximumLifetimeInMinutes ?? 1440 + const policyMin = Math.min(tapPolicyConfig?.minimumLifetimeInMinutes ?? 1, policyMax) formControl.setValue( - "tapLifetimeInMinutes", + 'tapLifetimeInMinutes', Math.min(Math.max(requestedMinutes, policyMin), policyMax) - ); - }, [useTAP, startDate, endDate, tapPolicy.data, formControl]); + ) + }, [useTAP, startDate, endDate, tapPolicy.data, formControl]) // Clear fields when switches are toggled off useEffect(() => { if (!useRoles) { - formControl.setValue("adminRoles", []); + formControl.setValue('adminRoles', []) } - }, [useRoles]); + }, [useRoles]) useEffect(() => { if (!useGroups) { - formControl.setValue("groupMemberships", []); + formControl.setValue('groupMemberships', []) } - }, [useGroups]); + }, [useGroups]) // Reset expiration action when switches change useEffect(() => { - const currentAction = formControl.getValues("expireAction"); - if (!currentAction?.value) return; + const currentAction = formControl.getValues('expireAction') + if (!currentAction?.value) return - if (!useRoles && currentAction.value === "RemoveRoles") { - formControl.setValue("expireAction", null); - } else if (!useGroups && currentAction.value === "RemoveGroups") { - formControl.setValue("expireAction", null); - } else if ((!useRoles || !useGroups) && currentAction.value === "RemoveRolesAndGroups") { - formControl.setValue("expireAction", null); - } else if (useRoles && useGroups && currentAction.value === "RemoveRoles") { - formControl.setValue("expireAction", null); - } else if (useRoles && useGroups && currentAction.value === "RemoveGroups") { - formControl.setValue("expireAction", null); + if (!useRoles && currentAction.value === 'RemoveRoles') { + formControl.setValue('expireAction', null) + } else if (!useGroups && currentAction.value === 'RemoveGroups') { + formControl.setValue('expireAction', null) + } else if ((!useRoles || !useGroups) && currentAction.value === 'RemoveRolesAndGroups') { + formControl.setValue('expireAction', null) + } else if (useRoles && useGroups && currentAction.value === 'RemoveRoles') { + formControl.setValue('expireAction', null) + } else if (useRoles && useGroups && currentAction.value === 'RemoveGroups') { + formControl.setValue('expireAction', null) } - }, [useRoles, useGroups]); + }, [useRoles, useGroups]) // Simple duration parser for basic ISO 8601 durations const parseDuration = (duration) => { - if (!duration) return null; + if (!duration) return null const matches = duration.match( /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/ - ); - if (!matches) return null; + ) + if (!matches) return null return { years: parseInt(matches[1] || 0), months: parseInt(matches[2] || 0), @@ -115,159 +114,159 @@ const Page = () => { hours: parseInt(matches[5] || 0), minutes: parseInt(matches[6] || 0), seconds: parseInt(matches[7] || 0), - }; - }; + } + } const addDurationToDate = (date, duration) => { - if (!date || !duration) return null; - const parsed = parseDuration(duration); - if (!parsed) return null; + if (!date || !duration) return null + const parsed = parseDuration(duration) + if (!parsed) return null - const result = new Date(date); - result.setFullYear(result.getFullYear() + parsed.years); - result.setMonth(result.getMonth() + parsed.months); - result.setDate(result.getDate() + parsed.weeks * 7); - result.setDate(result.getDate() + parsed.days); - result.setHours(result.getHours() + parsed.hours); - result.setMinutes(result.getMinutes() + parsed.minutes); - result.setSeconds(result.getSeconds() + parsed.seconds); - return result; - }; + const result = new Date(date) + result.setFullYear(result.getFullYear() + parsed.years) + result.setMonth(result.getMonth() + parsed.months) + result.setDate(result.getDate() + parsed.weeks * 7) + result.setDate(result.getDate() + parsed.days) + result.setHours(result.getHours() + parsed.hours) + result.setMinutes(result.getMinutes() + parsed.minutes) + result.setSeconds(result.getSeconds() + parsed.seconds) + return result + } // Auto-select default template for tenant // Priority: tenant-specific default > AllTenants default useEffect(() => { if (jitAdminTemplates.isSuccess && !watcher.jitAdminTemplate) { - const templates = jitAdminTemplates.data || []; + const templates = jitAdminTemplates.data || [] // First, try to find a tenant-specific default template let defaultTemplate = templates.find( (template) => template.defaultForTenant === true && - template.tenantFilter !== "AllTenants" && + template.tenantFilter !== 'AllTenants' && template.tenantFilter === selectedTenant?.value - ); + ) // If not found, fall back to AllTenants default template if (!defaultTemplate) { defaultTemplate = templates.find( - (template) => template.defaultForTenant === true && template.tenantFilter === "AllTenants" - ); + (template) => template.defaultForTenant === true && template.tenantFilter === 'AllTenants' + ) } if (defaultTemplate) { - formControl.setValue("jitAdminTemplate", { + formControl.setValue('jitAdminTemplate', { label: defaultTemplate.templateName, value: defaultTemplate.GUID, addedFields: defaultTemplate, - }); - setSelectedTemplate(defaultTemplate); + }) + setSelectedTemplate(defaultTemplate) } } - }, [jitAdminTemplates.isSuccess, selectedTenant]); + }, [jitAdminTemplates.isSuccess, selectedTenant]) // Only set template-driven fields when the template actually changes - const [lastTemplate, setLastTemplate] = useState(null); + const [lastTemplate, setLastTemplate] = useState(null) useEffect(() => { - const template = watcher.jitAdminTemplate?.addedFields; - if (!template || template.GUID === lastTemplate) return; - setSelectedTemplate(template); - setLastTemplate(template.GUID); + const template = watcher.jitAdminTemplate?.addedFields + if (!template || template.GUID === lastTemplate) return + setSelectedTemplate(template) + setLastTemplate(template.GUID) // Helpers const roundDown15 = (date) => { - const d = new Date(date); - d.setMilliseconds(0); - d.setSeconds(0); - d.setMinutes(Math.floor(d.getMinutes() / 15) * 15); - return d; - }; + const d = new Date(date) + d.setMilliseconds(0) + d.setSeconds(0) + d.setMinutes(Math.floor(d.getMinutes() / 15) * 15) + return d + } const roundUp15 = (date) => { - const d = new Date(date); - d.setMilliseconds(0); - d.setSeconds(0); - let min = d.getMinutes(); - d.setMinutes(min % 15 === 0 ? min : Math.ceil(min / 15) * 15); + const d = new Date(date) + d.setMilliseconds(0) + d.setSeconds(0) + let min = d.getMinutes() + d.setMinutes(min % 15 === 0 ? min : Math.ceil(min / 15) * 15) if (d.getMinutes() === 60) { - d.setHours(d.getHours() + 1); - d.setMinutes(0); + d.setHours(d.getHours() + 1) + d.setMinutes(0) } - return d; - }; + return d + } // Set all template-driven fields - formControl.setValue("useRoles", template.defaultUseRoles ?? true, { shouldDirty: true }); - formControl.setValue("useGroups", template.defaultUseGroups ?? false, { shouldDirty: true }); - formControl.setValue("adminRoles", template.defaultRoles || [], { shouldDirty: true }); - formControl.setValue("groupMemberships", template.defaultGroups || [], { shouldDirty: true }); - formControl.setValue("expireAction", template.defaultExpireAction || null, { + formControl.setValue('useRoles', template.defaultUseRoles ?? true, { shouldDirty: true }) + formControl.setValue('useGroups', template.defaultUseGroups ?? false, { shouldDirty: true }) + formControl.setValue('adminRoles', template.defaultRoles || [], { shouldDirty: true }) + formControl.setValue('groupMemberships', template.defaultGroups || [], { shouldDirty: true }) + formControl.setValue('expireAction', template.defaultExpireAction || null, { shouldDirty: true, - }); - formControl.setValue("postExecution", template.defaultNotificationActions || [], { + }) + formControl.setValue('postExecution', template.defaultNotificationActions || [], { shouldDirty: true, - }); - formControl.setValue("UseTAP", template.generateTAPByDefault ?? false, { shouldDirty: true }); - formControl.setValue("reason", template.reasonTemplate || "", { shouldDirty: true }); + }) + formControl.setValue('UseTAP', template.generateTAPByDefault ?? false, { shouldDirty: true }) + formControl.setValue('reason', template.reasonTemplate || '', { shouldDirty: true }) // User action and user details if (template.defaultUserAction) { - formControl.setValue("userAction", template.defaultUserAction, { shouldDirty: true }); + formControl.setValue('userAction', template.defaultUserAction, { shouldDirty: true }) } if (template.defaultFirstName) { - formControl.setValue("firstName", template.defaultFirstName, { shouldDirty: true }); + formControl.setValue('firstName', template.defaultFirstName, { shouldDirty: true }) } if (template.defaultLastName) { - formControl.setValue("lastName", template.defaultLastName, { shouldDirty: true }); + formControl.setValue('lastName', template.defaultLastName, { shouldDirty: true }) } if (template.defaultUserName) { - formControl.setValue("userName", template.defaultUserName, { shouldDirty: true }); + formControl.setValue('userName', template.defaultUserName, { shouldDirty: true }) } if (template.defaultDomain) { - formControl.setValue("domain", template.defaultDomain, { shouldDirty: true }); + formControl.setValue('domain', template.defaultDomain, { shouldDirty: true }) } if (template.defaultExistingUser) { - formControl.setValue("existingUser", template.defaultExistingUser, { shouldDirty: true }); + formControl.setValue('existingUser', template.defaultExistingUser, { shouldDirty: true }) } if (template.defaultUsageLocation) { - formControl.setValue("usageLocation", template.defaultUsageLocation, { shouldDirty: true }); + formControl.setValue('usageLocation', template.defaultUsageLocation, { shouldDirty: true }) } // Dates if (template.defaultDuration) { const duration = - typeof template.defaultDuration === "object" && template.defaultDuration !== null + typeof template.defaultDuration === 'object' && template.defaultDuration !== null ? template.defaultDuration.value - : template.defaultDuration; - const start = roundDown15(new Date()); - const unixStart = Math.floor(start.getTime() / 1000); - formControl.setValue("startDate", unixStart, { shouldDirty: true }); - const end = roundUp15(addDurationToDate(start, duration)); - const unixEnd = Math.floor(end.getTime() / 1000); - formControl.setValue("endDate", unixEnd, { shouldDirty: true }); + : template.defaultDuration + const start = roundDown15(new Date()) + const unixStart = Math.floor(start.getTime() / 1000) + formControl.setValue('startDate', unixStart, { shouldDirty: true }) + const end = roundUp15(addDurationToDate(start, duration)) + const unixEnd = Math.floor(end.getTime() / 1000) + formControl.setValue('endDate', unixEnd, { shouldDirty: true }) } - }, [watcher.jitAdminTemplate, lastTemplate]); + }, [watcher.jitAdminTemplate, lastTemplate]) // Recalculate end date when start date changes and template has default duration useEffect(() => { if (watcher.startDate && selectedTemplate?.defaultDuration) { const durationValue = - typeof selectedTemplate.defaultDuration === "object" && + typeof selectedTemplate.defaultDuration === 'object' && selectedTemplate.defaultDuration !== null ? selectedTemplate.defaultDuration.value - : selectedTemplate.defaultDuration; - const startDateDate = new Date(watcher.startDate * 1000); - const endDateObj = addDurationToDate(startDateDate, durationValue); + : selectedTemplate.defaultDuration + const startDateDate = new Date(watcher.startDate * 1000) + const endDateObj = addDurationToDate(startDateDate, durationValue) if (endDateObj) { - const unixEnd = Math.floor(endDateObj.getTime() / 1000); - formControl.setValue("endDate", unixEnd); + const unixEnd = Math.floor(endDateObj.getTime() / 1000) + formControl.setValue('endDate', unixEnd) } } - }, [watcher.startDate]); + }, [watcher.startDate]) return ( <> { @@ -313,11 +312,11 @@ const Page = () => { row formControl={formControl} options={[ - { label: "New User", value: "create" }, - { label: "Existing User", value: "select" }, + { label: 'New User', value: 'create' }, + { label: 'Existing User', value: 'select' }, ]} required={true} - validators={{ required: "You must select an option" }} + validators={{ required: 'You must select an option' }} /> @@ -335,7 +334,7 @@ const Page = () => { name="firstName" formControl={formControl} required={true} - validators={{ required: "First Name is required" }} + validators={{ required: 'First Name is required' }} /> @@ -346,7 +345,7 @@ const Page = () => { name="lastName" formControl={formControl} required={true} - validators={{ required: "Last Name is required" }} + validators={{ required: 'Last Name is required' }} /> @@ -357,7 +356,7 @@ const Page = () => { name="userName" formControl={formControl} required={true} - validators={{ required: "Username is required" }} + validators={{ required: 'Username is required' }} /> @@ -366,7 +365,7 @@ const Page = () => { name="domain" label="Domain Name" required={true} - validators={{ required: "Domain is required" }} + validators={{ required: 'Domain is required' }} /> @@ -380,6 +379,7 @@ const Page = () => { value: Code, }))} formControl={formControl} + creatable={false} /> @@ -400,7 +400,7 @@ const Page = () => { name="existingUser" label="User" required={true} - validators={{ required: "User is required" }} + validators={{ required: 'User is required' }} /> @@ -413,7 +413,7 @@ const Page = () => { name="startDate" formControl={formControl} required={true} - validators={{ required: "Start date is required" }} + validators={{ required: 'Start date is required' }} /> @@ -425,13 +425,13 @@ const Page = () => { formControl={formControl} required={true} validators={{ - required: "End date is required", + required: 'End date is required', validate: (value) => { - const startDate = formControl.getValues("startDate"); + const startDate = formControl.getValues('startDate') if (value && startDate && new Date(value) < new Date(startDate)) { - return "End date must be after start date"; + return 'End date must be after start date' } - return true; + return true }, }} /> @@ -452,7 +452,7 @@ const Page = () => { {!useRoles && !useGroups && ( - + Please select at least "Admin Roles" or "Group Membership" @@ -462,7 +462,7 @@ const Page = () => { field="useRoles" compareType="is" compareValue={true} - > + > { formControl={formControl} required={true} validators={{ - required: "At least one role is required", + required: 'At least one role is required', validate: (options) => { if (!options?.length) { - return "At least one role is required"; + return 'At least one role is required' } - return true; + return true }, }} + creatable={false} /> @@ -498,12 +499,12 @@ const Page = () => { multiple={true} required={true} validators={{ - required: "At least one group is required", + required: 'At least one group is required', validate: (options) => { if (!options?.length) { - return "At least one group is required"; + return 'At least one group is required' } - return true; + return true }, }} /> @@ -519,7 +520,7 @@ const Page = () => { rows={3} formControl={formControl} required={true} - validators={{ required: "A reason is required" }} + validators={{ required: 'A reason is required' }} /> @@ -535,12 +536,12 @@ const Page = () => { formControl={formControl} /> {useTAP && tapPolicy.isSuccess && !tapEnabled && ( - + TAP is not enabled in this tenant. TAP generation will fail. )} {useTAP && tapLifetimeInMinutes && ( - + TAP will be valid for {tapLifetimeInMinutes} minutes. )} @@ -556,20 +557,20 @@ const Page = () => { required={true} options={(() => { const opts = [ - { label: "Delete User", value: "DeleteUser" }, - { label: "Disable User", value: "DisableUser" }, - ]; + { label: 'Delete User', value: 'DeleteUser' }, + { label: 'Disable User', value: 'DisableUser' }, + ] if (useRoles && useGroups) { - opts.push({ label: "Remove Roles and Groups", value: "RemoveRolesAndGroups" }); + opts.push({ label: 'Remove Roles and Groups', value: 'RemoveRolesAndGroups' }) } else if (useRoles) { - opts.push({ label: "Remove Roles", value: "RemoveRoles" }); + opts.push({ label: 'Remove Roles', value: 'RemoveRoles' }) } else if (useGroups) { - opts.push({ label: "Remove Groups", value: "RemoveGroups" }); + opts.push({ label: 'Remove Groups', value: 'RemoveGroups' }) } - return opts; + return opts })()} formControl={formControl} - validators={{ required: "Expiration action is required" }} + validators={{ required: 'Expiration action is required' }} /> @@ -581,9 +582,9 @@ const Page = () => { multiple={true} creatable={false} options={[ - { label: "Webhook", value: "Webhook" }, - { label: "Email", value: "email" }, - { label: "PSA", value: "PSA" }, + { label: 'Webhook', value: 'Webhook' }, + { label: 'Email', value: 'email' }, + { label: 'PSA', value: 'PSA' }, ]} formControl={formControl} /> @@ -592,9 +593,9 @@ const Page = () => { - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page From 98a96fcbcf72717d266c53eeaca45ab4a074b28a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:17:15 +0200 Subject: [PATCH 153/164] CA expansion for tags --- src/pages/tenant/manage/applied-standards.js | 4 ++-- src/pages/tenant/manage/policies-deployed.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/tenant/manage/applied-standards.js b/src/pages/tenant/manage/applied-standards.js index d69b3aebc81c..234e5cb46b23 100644 --- a/src/pages/tenant/manage/applied-standards.js +++ b/src/pages/tenant/manage/applied-standards.js @@ -163,7 +163,7 @@ const Page = () => { templateItem['TemplateList-Tags']?.addedFields?.templates || templateItem['TemplateList-Tags']?.rawData?.templates - if (templateItem['TemplateList-Tags']?.value && tagTemplates) { + if (templateItem['TemplateList-Tags']?.value && tagTemplates?.length > 0) { tagTemplates.forEach((expandedTemplate) => { const itemTemplateId = expandedTemplate.GUID const standardId = `standards.IntuneTemplate.${itemTemplateId}` @@ -430,7 +430,7 @@ const Page = () => { templateItem['TemplateList-Tags']?.rawData?.templates // Check if this item has TemplateList-Tags and expand them - if (templateItem['TemplateList-Tags']?.value && tagTemplates) { + if (templateItem['TemplateList-Tags']?.value && tagTemplates?.length > 0) { tagTemplates.forEach((expandedTemplate) => { const itemTemplateId = expandedTemplate.GUID const standardId = `standards.ConditionalAccessTemplate.${itemTemplateId}` diff --git a/src/pages/tenant/manage/policies-deployed.js b/src/pages/tenant/manage/policies-deployed.js index 616f5a64491f..3b24143cee55 100644 --- a/src/pages/tenant/manage/policies-deployed.js +++ b/src/pages/tenant/manage/policies-deployed.js @@ -255,7 +255,7 @@ const PoliciesDeployedPage = () => { const templateListTags = template['TemplateList-Tags'] || template.TemplateListTags // Check if this template has TemplateList-Tags and expand them - if (templateListTags?.value && templateListTags?.addedFields?.templates) { + if (templateListTags?.value && templateListTags?.addedFields?.templates?.length > 0) { console.log( 'Found TemplateList-Tags for IntuneTemplate in policies-deployed:', templateListTags @@ -359,7 +359,7 @@ const PoliciesDeployedPage = () => { const templateListTags = template['TemplateList-Tags'] || template.TemplateListTags // Check if this template has TemplateList-Tags and expand them - if (templateListTags?.value && templateListTags?.addedFields?.templates) { + if (templateListTags?.value && templateListTags?.addedFields?.templates?.length > 0) { console.log( 'Found TemplateList-Tags for ConditionalAccessTemplate in policies-deployed:', templateListTags From a4aac4a128a847a5af059c4edc73f66af7014a60 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:17:18 +0200 Subject: [PATCH 154/164] CA expansion for tags --- src/pages/tenant/manage/applied-standards.js | 1191 +++++++++--------- 1 file changed, 614 insertions(+), 577 deletions(-) diff --git a/src/pages/tenant/manage/applied-standards.js b/src/pages/tenant/manage/applied-standards.js index 234e5cb46b23..6a8c0d5bea3f 100644 --- a/src/pages/tenant/manage/applied-standards.js +++ b/src/pages/tenant/manage/applied-standards.js @@ -1991,112 +1991,114 @@ const Page = () => { : 'This tenant does not have the required licenses for this standard'} ) : ( - <> - {/* Show Expected Configuration with property-by-property breakdown */} - {standard.currentTenantValue?.ExpectedValue !== undefined ? ( - - - Expected Configuration - - {typeof standard.currentTenantValue.ExpectedValue === 'object' && - standard.currentTenantValue.ExpectedValue !== null ? ( - - {Object.entries(standard.currentTenantValue.ExpectedValue).map( - ([key, val]) => ( - - - {key} - - + <> + {/* Show Expected Configuration with property-by-property breakdown */} + {standard.currentTenantValue?.ExpectedValue !== undefined ? ( + + + Expected Configuration + + {typeof standard.currentTenantValue.ExpectedValue === + 'object' && + standard.currentTenantValue.ExpectedValue !== null ? ( + + {Object.entries( + standard.currentTenantValue.ExpectedValue + ).map(([key, val]) => ( + - {val !== undefined - ? JSON.stringify(val, null, 2) - : 'Not set'} + {key} + + + {val !== undefined + ? JSON.stringify(val, null, 2) + : 'Not set'} + + - - ) + ))} + + ) : ( + + + {String(standard.currentTenantValue.ExpectedValue)} + + )} - - ) : ( - - - {String(standard.currentTenantValue.ExpectedValue)} - + ) : ( + + This data has not yet been collected. Collect the data by + selecting Refresh Data from the Actions dropdown on the top of + the page. + )} - - ) : ( - - This data has not yet been collected. Collect the data by selecting - Refresh Data from the Actions dropdown on the top of the page. - - )} - - - - + + + + )} @@ -2203,35 +2205,496 @@ const Page = () => { : 'This tenant does not have the required licenses for this standard'} ) : ( - <> - {/* Existing tenant comparison content */} - {typeof standard.currentTenantValue?.Value === 'object' && - standard.currentTenantValue?.Value !== null ? ( - - {standard.complianceStatus === 'Reporting Disabled' ? ( - - Reporting is disabled for this standard in the template - configuration. - + <> + {/* Existing tenant comparison content */} + {typeof standard.currentTenantValue?.Value === 'object' && + standard.currentTenantValue?.Value !== null ? ( + + {standard.complianceStatus === 'Reporting Disabled' ? ( + + Reporting is disabled for this standard in the template + configuration. + + ) : ( + <> + {standard.complianceStatus === 'Overridden' ? ( + + This setting is configured by template:{' '} + {standard.overridingTemplateName || + standard.overridingTemplateId} + + ) : standard.complianceStatus === 'Compliant' ? ( + <> + {/* Show Current value property-by-property for compliant standards */} + {standard.currentTenantValue?.CurrentValue !== + undefined ? ( + typeof standard.currentTenantValue.CurrentValue === + 'object' && + standard.currentTenantValue.CurrentValue !== null ? ( + + + Current Configuration + + {Object.entries( + standard.currentTenantValue.CurrentValue + ).map(([key, val]) => ( + + + {key} + + + + + + + {val !== undefined + ? JSON.stringify(val, null, 2) + : 'Not set'} + + + + ))} + + ) : ( + + + Current Configuration + + + + {String( + standard.currentTenantValue.CurrentValue + )} + + + + ) + ) : null} + + ) : ( + <> + {standard.currentTenantValue?.Value === false && ( + + This setting is not configured correctly + + )} + {/* Show Current value property-by-property for non-compliant standards */} + {standard.currentTenantValue?.CurrentValue !== + undefined && + (typeof standard.currentTenantValue.CurrentValue === + 'object' && + standard.currentTenantValue.CurrentValue !== null ? ( + + + Current Configuration + + {Object.entries( + standard.currentTenantValue.CurrentValue + ).map(([key, val]) => { + // Compare with expected value for this property + const expectedVal = + standard.currentTenantValue?.ExpectedValue?.[ + key + ] + const isMatch = (() => { + if (expectedVal === undefined) return false + // Deep comparison handling nested objects and case-insensitive strings + const compareDeep = (v1, v2) => { + if ( + typeof v1 === 'string' && + typeof v2 === 'string' + ) { + return ( + v1.toLowerCase() === v2.toLowerCase() + ) + } + if ( + typeof v1 === 'object' && + v1 !== null && + typeof v2 === 'object' && + v2 !== null + ) { + return ( + JSON.stringify(v1) === + JSON.stringify(v2) + ) + } + return ( + JSON.stringify(v1) === JSON.stringify(v2) + ) + } + return compareDeep(val, expectedVal) + })() + + return ( + + + {key} + + + {isMatch && ( + + + + )} + + {val !== undefined + ? JSON.stringify(val, null, 2) + : 'Not set'} + + + + ) + })} + + ) : ( + + + Current Configuration + + + + {String( + standard.currentTenantValue.CurrentValue + )} + + + + ))} + + )} + + {/* Only show values if they're not simple true/false that's already covered by the alerts above */} + {!( + standard.complianceStatus === 'Compliant' && + (standard.currentTenantValue?.Value === true || + standard.currentTenantValue?.Value === false) + ) && + Object.entries(standard.currentTenantValue) + .filter( + ([key]) => + key !== 'LastRefresh' && + key !== 'CurrentValue' && + key !== 'ExpectedValue' && + // Skip showing the Value field separately if it's just true/false + !( + key === 'Value' && + (standard.currentTenantValue?.Value === true || + standard.currentTenantValue?.Value === false) + ) + ) + .map(([key, value]) => { + const actualValue = key === 'Value' ? value : value + + const standardValueForKey = + standard.standardValue && + typeof standard.standardValue === 'object' + ? standard.standardValue[key] + : undefined + + const isDifferent = + standardValueForKey !== undefined && + JSON.stringify(actualValue) !== + JSON.stringify(standardValueForKey) + + // Format the display value + let displayValue + if (typeof value === 'object' && value !== null) { + displayValue = + value?.label || JSON.stringify(value, null, 2) + } else if (value === true) { + displayValue = 'Enabled' + } else if (value === false) { + displayValue = 'Disabled' + } else { + displayValue = String(value) + } + + return ( + + + {key}: + + + {displayValue} + + + ) + })} + + )} + ) : ( - <> - {standard.complianceStatus === 'Overridden' ? ( - + + {standard.complianceStatus === 'Reporting Disabled' ? ( + + Reporting is disabled for this standard in the template + configuration. + + ) : standard.complianceStatus === 'Overridden' ? ( + This setting is configured by template:{' '} {standard.overridingTemplateName || standard.overridingTemplateId} ) : standard.complianceStatus === 'Compliant' ? ( <> - {/* Show Current value property-by-property for compliant standards */} + {/* Show Current value property-by-property in card view */} {standard.currentTenantValue?.CurrentValue !== undefined ? ( typeof standard.currentTenantValue.CurrentValue === 'object' && @@ -2351,25 +2814,18 @@ const Page = () => { ) : ( <> - {standard.currentTenantValue?.Value === false && ( - + {(standard.currentTenantValue?.Value === false || + standard.currentTenantValue === false) && ( + This setting is not configured correctly )} - {/* Show Current value property-by-property for non-compliant standards */} - {standard.currentTenantValue?.CurrentValue !== undefined && - (typeof standard.currentTenantValue.CurrentValue === + {/* Show Current value property-by-property for non-compliant standards in card view */} + {standard.currentTenantValue?.CurrentValue !== undefined ? ( + typeof standard.currentTenantValue.CurrentValue === 'object' && standard.currentTenantValue.CurrentValue !== null ? ( - + { })} ) : ( - + { - ))} - - )} - - {/* Only show values if they're not simple true/false that's already covered by the alerts above */} - {!( - standard.complianceStatus === 'Compliant' && - (standard.currentTenantValue?.Value === true || - standard.currentTenantValue?.Value === false) - ) && - Object.entries(standard.currentTenantValue) - .filter( - ([key]) => - key !== 'LastRefresh' && - key !== 'CurrentValue' && - key !== 'ExpectedValue' && - // Skip showing the Value field separately if it's just true/false - !( - key === 'Value' && - (standard.currentTenantValue?.Value === true || - standard.currentTenantValue?.Value === false) - ) - ) - .map(([key, value]) => { - const actualValue = key === 'Value' ? value : value - - const standardValueForKey = - standard.standardValue && - typeof standard.standardValue === 'object' - ? standard.standardValue[key] - : undefined - - const isDifferent = - standardValueForKey !== undefined && - JSON.stringify(actualValue) !== - JSON.stringify(standardValueForKey) - - // Format the display value - let displayValue - if (typeof value === 'object' && value !== null) { - displayValue = - value?.label || JSON.stringify(value, null, 2) - } else if (value === true) { - displayValue = 'Enabled' - } else if (value === false) { - displayValue = 'Disabled' - } else { - displayValue = String(value) - } - - return ( - - - {key}: - - - {displayValue} - - ) - })} - - )} - - ) : ( - - {standard.complianceStatus === 'Reporting Disabled' ? ( - - Reporting is disabled for this standard in the template - configuration. - - ) : standard.complianceStatus === 'Overridden' ? ( - - This setting is configured by template:{' '} - {standard.overridingTemplateName || - standard.overridingTemplateId} - - ) : standard.complianceStatus === 'Compliant' ? ( - <> - {/* Show Current value property-by-property in card view */} - {standard.currentTenantValue?.CurrentValue !== undefined ? ( - typeof standard.currentTenantValue.CurrentValue === - 'object' && - standard.currentTenantValue.CurrentValue !== null ? ( - - - Current Configuration - - {Object.entries( - standard.currentTenantValue.CurrentValue - ).map(([key, val]) => ( - - - {key} - - - - - - - {val !== undefined - ? JSON.stringify(val, null, 2) - : 'Not set'} - - - - ))} - - ) : ( - - - Current Configuration - - - - {String(standard.currentTenantValue.CurrentValue)} - + ) : standard.currentTenantValue !== undefined && + standard.currentTenantValue?.Value !== true && + standard.currentTenantValue?.Value !== false ? ( + + {String( + standard.currentTenantValue?.Value !== undefined + ? standard.currentTenantValue?.Value + : standard.currentTenantValue + )} - - ) - ) : null} - - ) : ( - <> - {(standard.currentTenantValue?.Value === false || - standard.currentTenantValue === false) && ( - - This setting is not configured correctly - + ) : standard.currentTenantValue === undefined || + (standard.currentTenantValue?.Value === null && + standard.currentTenantValue?.CurrentValue === + undefined && + standard.currentTenantValue?.ExpectedValue === + undefined) ? ( + + This setting is not configured, or data has not been + collected. If you are getting this after data + collection, the tenant might not be licensed for this + feature + + ) : null} + )} - {/* Show Current value property-by-property for non-compliant standards in card view */} - {standard.currentTenantValue?.CurrentValue !== undefined ? ( - typeof standard.currentTenantValue.CurrentValue === - 'object' && - standard.currentTenantValue.CurrentValue !== null ? ( - - - Current Configuration - - {Object.entries( - standard.currentTenantValue.CurrentValue - ).map(([key, val]) => { - // Compare with expected value for this property - const expectedVal = - standard.currentTenantValue?.ExpectedValue?.[key] - const isMatch = (() => { - if (expectedVal === undefined) return false - // Deep comparison handling nested objects and case-insensitive strings - const compareDeep = (v1, v2) => { - if ( - typeof v1 === 'string' && - typeof v2 === 'string' - ) { - return v1.toLowerCase() === v2.toLowerCase() - } - if ( - typeof v1 === 'object' && - v1 !== null && - typeof v2 === 'object' && - v2 !== null - ) { - return JSON.stringify(v1) === JSON.stringify(v2) - } - return JSON.stringify(v1) === JSON.stringify(v2) - } - return compareDeep(val, expectedVal) - })() - - return ( - - - {key} - - - {isMatch && ( - - - - )} - - {val !== undefined - ? JSON.stringify(val, null, 2) - : 'Not set'} - - - - ) - })} - - ) : ( - - - Current Configuration - - - - {String(standard.currentTenantValue.CurrentValue)} - - - - ) - ) : standard.currentTenantValue !== undefined && - standard.currentTenantValue?.Value !== true && - standard.currentTenantValue?.Value !== false ? ( - - {String( - standard.currentTenantValue?.Value !== undefined - ? standard.currentTenantValue?.Value - : standard.currentTenantValue - )} - - ) : standard.currentTenantValue === undefined || - (standard.currentTenantValue?.Value === null && - standard.currentTenantValue?.CurrentValue === undefined && - standard.currentTenantValue?.ExpectedValue === - undefined) ? ( - - This setting is not configured, or data has not been - collected. If you are getting this after data collection, - the tenant might not be licensed for this feature - - ) : null} - + )} - - )} - + )} From 7b0c86990e1e5765992c31ad0650d1c63fcf7917 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:11:18 +0800 Subject: [PATCH 155/164] Update SsoMigrationDialog.jsx --- src/components/CippComponents/SsoMigrationDialog.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CippComponents/SsoMigrationDialog.jsx b/src/components/CippComponents/SsoMigrationDialog.jsx index 071b8032e8ab..26ce87aa338d 100644 --- a/src/components/CippComponents/SsoMigrationDialog.jsx +++ b/src/components/CippComponents/SsoMigrationDialog.jsx @@ -75,7 +75,7 @@ export const SsoMigrationDialog = ({ meData }) => { To get ready, CIPP needs to create an app registration in your tenant called{' '} - CIPP-SSO with minimal permissions (OpenID, Profile, Email only). + CIPP-SSO with minimal permissions (OpenID, Profile, Email only). This won't change how you log in today — it just prepares your tenant for when the update rolls out. From 8ae6ad1fae73702c180047ba374b491542dc0b4d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:19:13 +0800 Subject: [PATCH 156/164] typo --- src/components/CippComponents/ForcedSsoMigrationDialog.jsx | 2 +- src/components/CippComponents/SsoMigrationDialog.jsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/CippComponents/ForcedSsoMigrationDialog.jsx b/src/components/CippComponents/ForcedSsoMigrationDialog.jsx index abc303fe1797..7326ae56df0c 100644 --- a/src/components/CippComponents/ForcedSsoMigrationDialog.jsx +++ b/src/components/CippComponents/ForcedSsoMigrationDialog.jsx @@ -61,7 +61,7 @@ export const ForcedSsoMigrationDialog = () => { {!submitted ? ( <> - Your CIPP instance requires a dedicated CIPP-SSO app registration in + Your CIPP instance requires a dedicated CIPP-SSO app registration in your tenant for authentication. This gives you full control over Conditional Access policies, MFA requirements, and session management for your CIPP users. diff --git a/src/components/CippComponents/SsoMigrationDialog.jsx b/src/components/CippComponents/SsoMigrationDialog.jsx index 26ce87aa338d..03026daec36a 100644 --- a/src/components/CippComponents/SsoMigrationDialog.jsx +++ b/src/components/CippComponents/SsoMigrationDialog.jsx @@ -74,8 +74,8 @@ export const SsoMigrationDialog = ({ meData }) => { CIPP users. - To get ready, CIPP needs to create an app registration in your tenant called{' '} - CIPP-SSO with minimal permissions (OpenID, Profile, Email only). + To get ready, CIPP needs to create an app registration in your tenant called + CIPP-SSO with minimal permissions (OpenID, Profile, Email only). This won't change how you log in today — it just prepares your tenant for when the update rolls out. From c15d1d0df12c5aef9bd1d301a255ef5224c05f7d Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 5 Jun 2026 10:41:56 -0400 Subject: [PATCH 157/164] fix: sherweb integration conditional fields --- src/data/Extensions.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 7bb91b6165f9..83872799e810 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -144,7 +144,7 @@ ], "multiple": false, "condition": { - "field": "Sherweb.migrationMethods", + "field": "Sherweb.migrationMethods.value", "compareType": "is", "compareValue": "buyAndCancel" } @@ -181,7 +181,7 @@ "placeholder": "Enter your Pax8 Client ID", "required": true, "condition": { - "field": "Sherweb.migrateFrom", + "field": "Sherweb.migrateFrom.value", "compareType": "is", "compareValue": "Pax8" } @@ -193,7 +193,7 @@ "placeholder": "Enter your Pax Client Secret", "required": true, "condition": { - "field": "Sherweb.migrateFrom", + "field": "Sherweb.migrateFrom.value", "compareType": "is", "compareValue": "Pax8" } From 6c968c4bd3eb95439caf2c669e63bc7f1dd2fdd7 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 5 Jun 2026 14:36:10 -0400 Subject: [PATCH 158/164] fix: bad math --- src/pages/tenant/manage/applied-standards.js | 81 +++++++++++--------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/src/pages/tenant/manage/applied-standards.js b/src/pages/tenant/manage/applied-standards.js index 6a8c0d5bea3f..acd6a06a5061 100644 --- a/src/pages/tenant/manage/applied-standards.js +++ b/src/pages/tenant/manage/applied-standards.js @@ -1286,6 +1286,14 @@ const Page = () => { const filteredGroupedStandards = useMemo(() => { if (!groupedStandards) return {} + const isLicenseMissingStandard = (standard) => { + const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue + return ( + standard.currentTenantValue?.LicenseAvailable === false || + (typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:')) + ) + } + if (!searchQuery && filter === 'all') { return groupedStandards } @@ -1297,9 +1305,7 @@ const Page = () => { const categoryMatchesSearch = !searchQuery || category.toLowerCase().includes(searchLower) const filteredStandards = groupedStandards[category].filter((standard) => { - const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue - const hasLicenseMissing = - typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:') + const hasLicenseMissing = isLicenseMissingStandard(standard) const matchesFilter = filter === 'all' || @@ -1312,9 +1318,7 @@ const Page = () => { (filter === 'nonCompliantWithLicense' && standard.complianceStatus === 'Non-Compliant' && !hasLicenseMissing) || - (filter === 'nonCompliantWithoutLicense' && - standard.complianceStatus === 'Non-Compliant' && - hasLicenseMissing) + (filter === 'nonCompliantWithoutLicense' && hasLicenseMissing) const matchesSearch = !searchQuery || @@ -1350,52 +1354,59 @@ const Page = () => { const overriddenCount = comparisonData?.filter((standard) => standard.complianceStatus === 'Overridden').length || 0 + const isIncludedInScoring = (standard) => + standard.complianceStatus !== 'Reporting Disabled' && standard.complianceStatus !== 'Overridden' + + const isLicenseMissingStandard = (standard) => { + const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue + return ( + standard.currentTenantValue?.LicenseAvailable === false || + (typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:')) + ) + } + // Calculate license-related metrics const missingLicenseCount = - comparisonData?.filter((standard) => { - const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue - return typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:') - }).length || 0 + comparisonData?.filter( + (standard) => isIncludedInScoring(standard) && isLicenseMissingStandard(standard) + ).length || 0 const nonCompliantWithLicenseCount = comparisonData?.filter((standard) => { - const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue - return ( - standard.complianceStatus === 'Non-Compliant' && - !(typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:')) - ) + return standard.complianceStatus === 'Non-Compliant' && !isLicenseMissingStandard(standard) }).length || 0 const nonCompliantWithoutLicenseCount = - comparisonData?.filter((standard) => { - const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue - return ( - standard.complianceStatus === 'Non-Compliant' && - typeof tenantValue === 'string' && - tenantValue.startsWith('License Missing:') - ) - }).length || 0 + comparisonData?.filter((standard) => isLicenseMissingStandard(standard)).length || 0 + + const compliantWithAvailableLicenseCount = + comparisonData?.filter( + (standard) => + isIncludedInScoring(standard) && + (standard.complianceStatus === 'Compliant' || + standard.complianceStatus === 'Accepted Deviation' || + standard.complianceStatus === 'Customer Specific') && + !isLicenseMissingStandard(standard) + ).length || 0 + + const scoredStandardsCount = Math.max(allCount - reportingDisabledCount - overriddenCount, 0) const compliancePercentage = - allCount > 0 - ? Math.round( - ((compliantCount + acceptedDeviationCount) / - (allCount - reportingDisabledCount - overriddenCount || 1)) * - 100 - ) + scoredStandardsCount > 0 + ? Math.round((compliantWithAvailableLicenseCount / scoredStandardsCount) * 100) : 0 const missingLicensePercentage = - allCount > 0 + scoredStandardsCount > 0 ? Math.round((missingLicenseCount / scoredStandardsCount) * 100) : 0 + + // Combined score: standards either compliant with available licensing or blocked by missing license. + const combinedScore = + scoredStandardsCount > 0 ? Math.round( - (missingLicenseCount / (allCount - reportingDisabledCount - overriddenCount || 1)) * 100 + ((compliantWithAvailableLicenseCount + missingLicenseCount) / scoredStandardsCount) * 100 ) : 0 - // Combined score: compliance percentage + missing license percentage - // This represents the total "addressable" compliance (compliant + could be compliant if licensed) - const combinedScore = compliancePercentage + missingLicensePercentage - // Simple filter for all templates (no type filtering) const templateOptions = templates ? templates.map((template) => ({ From e90b0ff971de99d70d5ee1e10a98c183dc1f97e7 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:46:30 +0200 Subject: [PATCH 159/164] renumber for cis7 --- src/data/standards.json | 212 ++++++++++++++++++++-------------------- 1 file changed, 107 insertions(+), 105 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index fc2b40fc34ec..c1e87f49e615 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -126,7 +126,7 @@ { "name": "standards.AuditLog", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (3.1.1)", "mip_search_auditlog", "NIST CSF 2.0 (DE.CM-09)"], + "tag": ["CIS M365 7.0.0 (3.1.1)", "mip_search_auditlog", "NIST CSF 2.0 (DE.CM-09)"], "appliesToTest": ["CISAMSEXO171", "CISAMSEXO173", "CIS_3_1_1"], "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.", @@ -148,7 +148,7 @@ { "name": "standards.RestrictThirdPartyStorageServices", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (1.3.7)"], + "tag": ["CIS M365 7.0.0 (1.3.7)"], "appliesToTest": ["CIS_1_3_7"], "helpText": "Restricts third-party storage services in Microsoft 365 on the web by managing the Microsoft 365 on the web service principal. This disables integrations with services like Dropbox, Google Drive, Box, and other third-party storage providers.", "docsDescription": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. This standard ensures Microsoft 365 on the web third-party storage services are restricted by creating and disabling the Microsoft 365 on the web service principal (appId: c1f33bc0-bdb4-4248-ba9b-096807ddb43e). By using external storage services an organization may increase the risk of data breaches and unauthorized access to confidential information. Additionally, third-party services may not adhere to the same security standards as the organization, making it difficult to maintain data privacy and security. Impact is highly dependent upon current practices - if users do not use other storage providers, then minimal impact is likely. However, if users regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", @@ -267,7 +267,7 @@ { "name": "standards.EnableCustomerLockbox", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (1.3.6)", "CustomerLockBoxEnabled"], + "tag": ["CIS M365 7.0.0 (1.3.6)", "CustomerLockBoxEnabled"], "appliesToTest": ["CIS_1_3_6"], "helpText": "**Requires Entra ID P2.** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", "docsDescription": "**Requires Entra ID P2.** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", @@ -328,7 +328,7 @@ "name": "standards.DisableGuestDirectory", "cat": "Global Standards", "tag": [ - "CIS M365 6.0.1 (5.1.6.2)", + "CIS M365 7.0.0 (5.1.6.2)", "CISA (MS.AAD.5.1v1)", "EIDSCA.AP14", "EIDSCA.ST08", @@ -359,7 +359,7 @@ { "name": "standards.DisableBasicAuthSMTP", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)"], + "tag": ["CIS M365 7.0.0 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)"], "appliesToTest": ["CISAMSEXO51", "CIS_6_5_4", "ZTNA21799"], "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)", "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).", @@ -382,7 +382,7 @@ { "name": "standards.ActivityBasedTimeout", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (1.3.2)", "spo_idle_session_timeout", "NIST CSF 2.0 (PR.AA-03)"], + "tag": ["CIS M365 7.0.0 (1.3.2)", "spo_idle_session_timeout", "NIST CSF 2.0 (PR.AA-03)"], "appliesToTest": ["CIS_1_3_2", "ZTNA21813", "ZTNA21814", "ZTNA21815"], "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.", @@ -413,7 +413,7 @@ "name": "standards.AuthMethodsSettings", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.6)", + "CIS M365 7.0.0 (5.2.3.6)", "EIDSCA.AG01", "EIDSCA.AG02", "EIDSCA.AG03", @@ -915,7 +915,7 @@ { "name": "standards.laps", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.5)", "SMB1001 (2.2)"], + "tag": ["CIS M365 7.0.0 (5.1.4.5)", "SMB1001 (2.2)"], "appliesToTest": ["CIS_5_1_4_5", "SMB1001_2_2", "ZTNA21953", "ZTNA21955", "ZTNA24560"], "helpText": "Enables the tenant to use LAPS. You must still create a policy for LAPS to be active on all devices. Use the template standards to deploy this by default.", "docsDescription": "Enables the LAPS functionality on the tenant. Prerequisite for using Windows LAPS via Azure AD.", @@ -932,7 +932,7 @@ "name": "standards.PWdisplayAppInformationRequiredState", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.1)", + "CIS M365 7.0.0 (5.2.3.1)", "EIDSCA.AM03", "EIDSCA.AM04", "EIDSCA.AM06", @@ -981,8 +981,8 @@ { "name": "standards.PWcompanionAppAllowedState", "cat": "Entra (AAD) Standards", - "tag": ["EIDSCA.AM01"], - "appliesToTest": ["EIDSCAAM01"], + "tag": ["CIS M365 7.0.0 (5.2.3.10)", "EIDSCA.AM01"], + "appliesToTest": ["CIS_5_2_3_10", "EIDSCAAM01"], "helpText": "Sets the state of Authenticator Lite, Authenticator lite is a companion app for passwordless authentication.", "docsDescription": "Sets the Authenticator Lite state to enabled. This allows users to use the Authenticator Lite built into the Outlook app instead of the full Authenticator app.", "executiveText": "Enables a simplified authentication experience by allowing users to authenticate directly through Outlook without requiring a separate authenticator app. This improves user convenience while maintaining security standards for passwordless authentication.", @@ -1081,7 +1081,7 @@ { "name": "standards.FormsPhishingProtection", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (1.3.5)", "Security", "PhishingProtection"], + "tag": ["CIS M365 7.0.0 (1.3.5)", "Security", "PhishingProtection"], "appliesToTest": ["CIS_1_3_5"], "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.", "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.", @@ -1125,7 +1125,7 @@ { "name": "standards.PasswordExpireDisabled", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (1.3.1)", "PWAgePolicyNew"], + "tag": ["CIS M365 7.0.0 (1.3.1)", "PWAgePolicyNew"], "appliesToTest": ["CIS_1_3_1", "ZTNA21811"], "helpText": "Disables the expiration of passwords for the tenant by setting the password expiration policy to never expire for any user.", "docsDescription": "Sets passwords to never expire for tenant, recommended to use in conjunction with secure password requirements.", @@ -1141,7 +1141,7 @@ { "name": "standards.CustomBannedPasswordList", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.2.3.2)", "SMB1001 (2.1)"], + "tag": ["CIS M365 7.0.0 (5.2.3.2)", "SMB1001 (2.1)"], "appliesToTest": [ "CIS_5_2_3_2", "EIDSCAPR01", @@ -1203,7 +1203,7 @@ { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.2.3)", "CISA (MS.AAD.6.1v1)", "SMB1001 (2.8)"], + "tag": ["CIS M365 7.0.0 (5.1.2.3)", "CISA (MS.AAD.6.1v1)", "SMB1001 (2.8)"], "appliesToTest": ["CIS_5_1_2_3", "SMB1001_2_8", "ZTNA21772", "ZTNA21787"], "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles.", "docsDescription": "Users by default are allowed to create M365 tenants. This disables that so only admins can create new M365 tenants.", @@ -1220,7 +1220,7 @@ "name": "standards.EnableAppConsentRequests", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.1.5.2)", + "CIS M365 7.0.0 (5.1.5.2)", "CISA (MS.AAD.9.1v1)", "EIDSCA.CP04", "EIDSCA.CR01", @@ -1323,7 +1323,7 @@ "name": "standards.DisableAppCreation", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.1.2.2)", + "CIS M365 7.0.0 (5.1.2.2)", "CISA (MS.AAD.4.1v1)", "EIDSCA.AP10", "Essential 8 (1175)", @@ -1345,7 +1345,7 @@ { "name": "standards.BitLockerKeysForOwnedDevice", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.6)"], + "tag": ["CIS M365 7.0.0 (5.1.4.6)"], "appliesToTest": ["CIS_5_1_4_6", "ZTNA21954"], "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.", @@ -1374,12 +1374,12 @@ "name": "standards.DisableSecurityGroupUsers", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.1.3.2)", + "CIS M365 7.0.0 (5.1.3.1)", "CISA (MS.AAD.20.1v1)", "NIST CSF 2.0 (PR.AA-05)", "SMB1001 (2.8)" ], - "appliesToTest": ["CIS_5_1_3_2", "SMB1001_2_8", "ZTNA21868"], + "appliesToTest": ["CIS_5_1_3_1", "SMB1001_2_8", "ZTNA21868"], "helpText": "Completely disables the creation of security groups by users. This also breaks the ability to manage groups themselves, or create Teams", "executiveText": "Restricts the creation of security groups to IT administrators only, preventing employees from creating unauthorized access groups that could bypass security controls. This ensures proper governance of access permissions and maintains centralized control over who can access what resources.", "addedComponent": [], @@ -1407,7 +1407,7 @@ { "name": "standards.DisableSelfServiceLicenses", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (1.3.4)", "SMB1001 (2.8)"], + "tag": ["CIS M365 7.0.0 (1.3.4)", "SMB1001 (2.8)"], "appliesToTest": ["CIS_1_3_4", "EIDSCAAP05", "SMB1001_2_8"], "helpText": "**Requires 'Billing Administrator' GDAP role.** This standard disables all self service licenses and enables all exclusions", "executiveText": "Prevents employees from purchasing Microsoft 365 licenses independently, ensuring all software acquisitions go through proper procurement channels. This maintains budget control, prevents unauthorized spending, and ensures compliance with corporate licensing agreements.", @@ -1459,7 +1459,7 @@ "name": "standards.OauthConsent", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.1.5.1)", + "CIS M365 7.0.0 (5.1.5.1)", "CISA (MS.AAD.4.2v1)", "EIDSCA.AP08", "EIDSCA.AP09", @@ -1603,7 +1603,7 @@ "name": "standards.DisableSMS", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.5)", + "CIS M365 7.0.0 (5.2.3.5)", "EIDSCA.AS04", "NIST CSF 2.0 (PR.AA-03)", "SMB1001 (2.5)", @@ -1633,7 +1633,7 @@ "name": "standards.DisableVoice", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.5)", + "CIS M365 7.0.0 (5.2.3.5)", "EIDSCA.AV01", "NIST CSF 2.0 (PR.AA-03)", "SMB1001 (2.5)", @@ -1663,7 +1663,7 @@ "name": "standards.DisableEmail", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.7)", + "CIS M365 7.0.0 (5.2.3.7)", "NIST CSF 2.0 (PR.AA-03)", "SMB1001 (2.5)", "SMB1001 (2.6)", @@ -1790,7 +1790,8 @@ { "name": "standards.AppManagementPolicy", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (5.1.5.3)", "CIS M365 7.0.0 (5.1.5.4)", "CIS M365 7.0.0 (5.1.5.5)", "CIS M365 7.0.0 (5.1.5.6)"], + "appliesToTest": ["CIS_5_1_5_3", "CIS_5_1_5_4", "CIS_5_1_5_5", "CIS_5_1_5_6"], "helpText": "Configures the default app management policy to control application and service principal credential restrictions such as password and key credential lifetimes.", "docsDescription": "Configures the default app management policy to control application and service principal credential restrictions. This includes password addition restrictions, custom password addition, symmetric key addition, and credential lifetime limits for both applications and service principals.", "executiveText": "Enforces credential restrictions on application registrations and service principals to limit how secrets and certificates are created and how long they remain valid. This reduces the risk of long-lived or unmanaged credentials being used to access your tenant.", @@ -1843,7 +1844,7 @@ { "name": "standards.OutBoundSpamAlert", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (2.1.6)"], + "tag": ["CIS M365 7.0.0 (2.1.6)"], "appliesToTest": ["CIS_2_1_6"], "helpText": "Set the Outbound Spam Alert e-mail address", "docsDescription": "Sets the e-mail address to which outbound spam alerts are sent.", @@ -2121,7 +2122,7 @@ { "name": "standards.SpoofWarn", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (6.2.3)"], + "tag": ["CIS M365 7.0.0 (6.2.3)"], "appliesToTest": ["CISAMSEXO71", "CIS_6_2_3", "ORCA111", "ORCA240"], "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", @@ -2163,7 +2164,7 @@ { "name": "standards.EnableMailTips", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (6.5.2)", "exo_mailtipsenabled"], + "tag": ["CIS M365 7.0.0 (6.5.2)", "exo_mailtipsenabled"], "appliesToTest": ["CIS_6_5_2"], "helpText": "Enables all MailTips in Outlook. MailTips are the notifications Outlook and Outlook on the web shows when an email you create, meets some requirements", "executiveText": "Enables helpful notifications in Outlook that warn users about potential email issues, such as sending to large groups, external recipients, or invalid addresses. This reduces email mistakes and improves communication efficiency by providing real-time guidance to employees.", @@ -2241,7 +2242,7 @@ { "name": "standards.RotateDKIM", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (2.1.9)", "SMB1001 (2.12)"], + "tag": ["CIS M365 7.0.0 (2.1.9)", "SMB1001 (2.12)"], "appliesToTest": ["CIS_2_1_9", "SMB1001_2_12"], "helpText": "Rotate DKIM keys that are 1024 bit to 2048 bit", "executiveText": "Upgrades email security by replacing older 1024-bit encryption keys with stronger 2048-bit keys for email authentication. This improves the organization's email security posture and helps prevent email spoofing and tampering, maintaining trust with email recipients.", @@ -2295,7 +2296,7 @@ { "name": "standards.AddDKIM", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (2.1.9)", "SMB1001 (2.12)"], + "tag": ["CIS M365 7.0.0 (2.1.9)", "SMB1001 (2.12)"], "appliesToTest": ["CISAMSEXO31", "CIS_2_1_9", "ORCA108", "ORCA108_1", "SMB1001_2_12"], "helpText": "Enables DKIM for all domains that currently support it", "executiveText": "Enables email authentication technology that digitally signs outgoing emails to verify they actually came from your organization. This prevents email spoofing, improves email deliverability, and protects the company's reputation by ensuring recipients can trust emails from your domains.", @@ -2317,7 +2318,7 @@ { "name": "standards.AddDMARCToMOERA", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (2.1.10)", "Security", "PhishingProtection", "SMB1001 (2.12)"], + "tag": ["CIS M365 7.0.0 (2.1.10)", "Security", "PhishingProtection", "SMB1001 (2.12)"], "appliesToTest": ["CIS_2_1_10", "SMB1001_2_12"], "helpText": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default value is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "docsDescription": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. Adds a DMARC record to MOERA (onmicrosoft.com) domains. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default record is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", @@ -2348,9 +2349,9 @@ "name": "standards.EnableMailboxAuditing", "cat": "Exchange Standards", "tag": [ - "CIS M365 6.0.1 (6.1.1)", - "CIS M365 6.0.1 (6.1.2)", - "CIS M365 6.0.1 (6.1.3)", + "CIS M365 7.0.0 (6.1.1)", + "CIS M365 7.0.0 (6.1.2)", + "CIS M365 7.0.0 (6.1.3)", "exo_mailboxaudit", "Essential 8 (1509)", "Essential 8 (1683)", @@ -2557,7 +2558,7 @@ { "name": "standards.EXOOutboundSpamLimits", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (2.1.15)"], + "tag": ["CIS M365 7.0.0 (2.1.15)"], "appliesToTest": ["CIS_2_1_15"], "helpText": "Configures the outbound spam recipient limits (external per hour, internal per hour, per day) and the action to take when a limit is reached. The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one. ", "docsDescription": "Configures the Exchange Online outbound spam recipient limits for external per hour, internal per hour, and per day, along with the action to take (e.g., BlockUser, Alert) when these limits are exceeded. This helps prevent abuse and manage email flow. Microsoft's recommendations can be found [here.](https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#eop-outbound-spam-policy-settings) The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one.", @@ -2626,7 +2627,7 @@ { "name": "standards.DisableExternalCalendarSharing", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (1.3.3)", "exo_individualsharing"], + "tag": ["CIS M365 7.0.0 (1.3.3)", "exo_individualsharing"], "appliesToTest": ["CISAMSEXO62", "CIS_1_3_3", "ZTNA21803"], "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", @@ -2665,7 +2666,7 @@ { "name": "standards.DisableAdditionalStorageProviders", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (6.5.3)", "exo_storageproviderrestricted"], + "tag": ["CIS M365 7.0.0 (6.5.3)", "exo_storageproviderrestricted"], "appliesToTest": ["CIS_6_5_3", "ZTNA21817"], "helpText": "Disables the ability for users to open files in Outlook on the Web, from other providers such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.", "docsDescription": "Disables additional storage providers in OWA. This is to prevent users from using personal storage providers like Dropbox, Google Drive, etc. Usually this has little user impact.", @@ -2688,7 +2689,7 @@ { "name": "standards.AntiSpamSafeList", "cat": "Defender Standards", - "tag": ["CIS M365 6.0.1 (2.1.13)"], + "tag": ["CIS M365 7.0.0 (2.1.13)"], "appliesToTest": ["CIS_2_1_13"], "helpText": "Sets the anti-spam connection filter policy option 'safe list' in Defender.", "docsDescription": "Sets [Microsoft's built-in 'safe list'](https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps#-enablesafelist) in the anti-spam connection filter policy, rather than setting a custom safe/block list of IPs.", @@ -2770,7 +2771,7 @@ { "name": "standards.Bookings", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (1.3.9)"], + "tag": ["CIS M365 7.0.0 (1.3.9)"], "appliesToTest": ["CIS_1_3_9"], "helpText": "Sets the state of Bookings on the tenant. Bookings is a scheduling tool that allows users to book appointments with others both internal and external.", "docsDescription": "", @@ -2804,7 +2805,7 @@ { "name": "standards.EXODirectSend", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (6.5.5)"], + "tag": ["CIS M365 7.0.0 (6.5.5)"], "appliesToTest": ["CIS_6_5_5"], "helpText": "Sets the state of Direct Send in Exchange Online. Direct Send allows applications to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication.", "docsDescription": "Controls whether applications can use Direct Send to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication. A detailed explanation from Microsoft can be found [here.](https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365)", @@ -2833,7 +2834,7 @@ "name": "standards.DisableOutlookAddins", "cat": "Exchange Standards", "tag": [ - "CIS M365 6.0.1 (6.3.1)", + "CIS M365 7.0.0 (6.3.1)", "exo_outlookaddins", "NIST CSF 2.0 (PR.AA-05)", "NIST CSF 2.0 (PR.PS-05)" @@ -2978,7 +2979,7 @@ { "name": "standards.UserSubmissions", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (8.6.1)"], + "tag": ["CIS M365 7.0.0 (8.6.1)"], "appliesToTest": ["CIS_8_6_1"], "helpText": "Set the state of the spam submission button in Outlook", "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish.", @@ -3019,7 +3020,7 @@ "name": "standards.DisableSharedMailbox", "cat": "Exchange Standards", "tag": [ - "CIS M365 6.0.1 (1.2.2)", + "CIS M365 7.0.0 (1.2.2)", "CISA (MS.AAD.10.1v1)", "NIST CSF 2.0 (PR.AA-01)", "SMB1001 (2.3)" @@ -3063,7 +3064,7 @@ "name": "standards.EXODisableAutoForwarding", "cat": "Exchange Standards", "tag": [ - "CIS M365 6.0.1 (6.2.1)", + "CIS M365 7.0.0 (6.2.1)", "mdo_autoforwardingmode", "mdo_blockmailforward", "CISA (MS.EXO.4.1v1)", @@ -3220,7 +3221,7 @@ "name": "standards.SafeLinksPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 6.0.1 (2.1.1)", + "CIS M365 7.0.0 (2.1.1)", "mdo_safelinksforemail", "mdo_safelinksforOfficeApps", "NIST CSF 2.0 (DE.CM-09)" @@ -3304,7 +3305,7 @@ "mdo_spam_notifications_only_for_admins", "mdo_antiphishingpolicies", "mdo_phishthresholdlevel", - "CIS M365 6.0.1 (2.1.7)", + "CIS M365 7.0.0 (2.1.7)", "NIST CSF 2.0 (DE.CM-09)" ], "appliesToTest": [ @@ -3498,7 +3499,7 @@ "name": "standards.SafeAttachmentPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 6.0.1 (2.1.4)", + "CIS M365 7.0.0 (2.1.4)", "mdo_safedocuments", "mdo_commonattachmentsfilter", "mdo_safeattachmentpolicy", @@ -3570,7 +3571,7 @@ { "name": "standards.AtpPolicyForO365", "cat": "Defender Standards", - "tag": ["CIS M365 6.0.1 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], + "tag": ["CIS M365 7.0.0 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], "appliesToTest": ["CIS_2_1_5", "ORCA225"], "helpText": "This creates a Atp policy that enables Defender for Office 365 for SharePoint, OneDrive and Microsoft Teams.", "addedComponent": [ @@ -3654,9 +3655,9 @@ "name": "standards.MalwareFilterPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 6.0.1 (2.1.2)", - "CIS M365 6.0.1 (2.1.3)", - "CIS M365 6.0.1 (2.1.11)", + "CIS M365 7.0.0 (2.1.2)", + "CIS M365 7.0.0 (2.1.3)", + "CIS M365 7.0.0 (2.1.11)", "mdo_zapspam", "mdo_zapphish", "mdo_zapmalware", @@ -4336,7 +4337,7 @@ { "name": "standards.IntuneComplianceSettings", "cat": "Intune Standards", - "tag": ["CIS M365 6.0.1 (4.1)"], + "tag": ["CIS M365 7.0.0 (4.1)"], "appliesToTest": ["CIS_4_1"], "helpText": "Sets the mark devices with no compliance policy assigned as compliance/non compliant and Compliance status validity period.", "executiveText": "Configures how the system treats devices that don't have specific compliance policies and sets how often devices must check in to maintain their compliance status. This ensures proper security oversight of all corporate devices and maintains current compliance information.", @@ -4408,7 +4409,7 @@ { "name": "standards.DefaultPlatformRestrictions", "cat": "Intune Standards", - "tag": ["CIS M365 6.0.1 (4.2)", "CISA (MS.AAD.19.1v1)"], + "tag": ["CIS M365 7.0.0 (4.2)", "CISA (MS.AAD.19.1v1)"], "appliesToTest": ["CIS_4_2"], "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", "executiveText": "Controls which types of devices (iOS, Android, Windows, macOS) and ownership models (corporate vs. personal) can be enrolled in the company's device management system. This helps maintain security standards while supporting necessary business device types and usage scenarios.", @@ -4650,7 +4651,7 @@ { "name": "standards.intuneDeviceReg", "cat": "Intune Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.2)", "CISA (MS.AAD.17.1v1)"], + "tag": ["CIS M365 7.0.0 (5.1.4.2)", "CISA (MS.AAD.17.1v1)"], "appliesToTest": ["CIS_5_1_4_2", "ZTNA21801", "ZTNA21802", "ZTNA21837"], "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", "executiveText": "Limits how many devices each employee can register for corporate access, preventing excessive device proliferation while accommodating legitimate business needs. This helps maintain security oversight and prevents potential abuse of device registration privileges.", @@ -4673,7 +4674,7 @@ { "name": "standards.intuneDeviceRegLocalAdmins", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.3)", "CIS M365 6.0.1 (5.1.4.4)", "SMB1001 (2.2)"], + "tag": ["CIS M365 7.0.0 (5.1.4.3)", "CIS M365 7.0.0 (5.1.4.4)", "SMB1001 (2.2)"], "appliesToTest": ["CIS_5_1_4_3", "CIS_5_1_4_4", "SMB1001_2_2"], "helpText": "Controls whether users who register Microsoft Entra joined devices are granted local administrator rights on those devices and if Global Administrators are added as local admins.", "docsDescription": "Configures the Device Registration Policy local administrator behavior for registering users. When enabled, users who register devices are not granted local administrator rights, you can also configure if Global Administrators are added as local admins.", @@ -4725,7 +4726,7 @@ { "name": "standards.intuneRestrictUserDeviceJoin", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.1)", "SMB1001 (2.8)"], + "tag": ["CIS M365 7.0.0 (5.1.4.1)", "SMB1001 (2.8)"], "appliesToTest": ["CIS_5_1_4_1", "SMB1001_2_8"], "helpText": "Controls whether users can join devices to Entra.", "docsDescription": "Configures whether users can join devices to Entra. When disabled, users are unable to Entra-join devices, which prevents them from creating new Entra-joined (cloud-managed) device identities.", @@ -4874,7 +4875,7 @@ { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.2)"], + "tag": ["CIS M365 7.0.0 (7.2.2)"], "appliesToTest": ["CIS_7_2_2"], "helpText": "Ensure SharePoint and OneDrive integration with Azure AD B2B is enabled", "executiveText": "Enables secure collaboration with external partners through SharePoint and OneDrive by integrating with Azure B2B guest access. This allows controlled sharing with external organizations while maintaining security oversight and proper access management.", @@ -4897,7 +4898,7 @@ { "name": "standards.SPDisallowInfectedFiles", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.3.1)", "CISA (MS.SPO.3.1v1)", "NIST CSF 2.0 (DE.CM-09)"], + "tag": ["CIS M365 7.0.0 (7.3.1)", "CISA (MS.SPO.3.1v1)", "NIST CSF 2.0 (DE.CM-09)"], "appliesToTest": ["CIS_7_3_1", "ZTNA21817"], "helpText": "Ensure Office 365 SharePoint infected files are disallowed for download", "executiveText": "Prevents employees from downloading files that have been identified as containing malware or viruses from SharePoint and OneDrive. This security measure protects against malware distribution through file sharing while maintaining access to clean, safe documents.", @@ -4964,7 +4965,7 @@ { "name": "standards.SPExternalUserExpiration", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.9)", "CISA (MS.SPO.1.5v1)"], + "tag": ["CIS M365 7.0.0 (7.2.9)", "CISA (MS.SPO.1.5v1)"], "appliesToTest": ["CIS_7_2_9", "ZTNA21803", "ZTNA21804", "ZTNA21858"], "helpText": "Ensure guest access to a site or OneDrive will expire automatically", "executiveText": "Automatically expires external user access to SharePoint sites and OneDrive after a specified period, reducing security risks from forgotten or unnecessary guest accounts. This ensures external access is regularly reviewed and maintained only when actively needed.", @@ -4998,7 +4999,7 @@ { "name": "standards.SPEmailAttestation", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.10)", "CISA (MS.SPO.1.6v1)"], + "tag": ["CIS M365 7.0.0 (7.2.10)", "CISA (MS.SPO.1.6v1)"], "appliesToTest": ["CIS_7_2_10", "ZTNA21803", "ZTNA21804"], "helpText": "Ensure re-authentication with verification code is restricted", "executiveText": "Requires external users to periodically re-verify their identity through email verification codes when accessing SharePoint resources, adding an extra security layer for external collaboration. This helps ensure continued legitimacy of external access over time.", @@ -5032,7 +5033,7 @@ { "name": "standards.DefaultSharingLink", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.7)", "CIS M365 6.0.1 (7.2.11)", "CISA (MS.SPO.1.4v1)"], + "tag": ["CIS M365 7.0.0 (7.2.7)", "CIS M365 7.0.0 (7.2.11)", "CISA (MS.SPO.1.4v1)"], "appliesToTest": ["CIS_7_2_11", "CIS_7_2_7", "ZTNA21803", "ZTNA21804"], "helpText": "Configure the SharePoint default sharing link type and permission. This setting controls both the type of sharing link created by default and the permission level assigned to those links.", "docsDescription": "Sets the default sharing link type (Direct or Internal) and permission (View) in SharePoint and OneDrive. Direct sharing means links only work for specific people, while Internal sharing means links work for anyone in the organization. Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link, reducing the risk of unintentionally granting edit privileges.", @@ -5138,7 +5139,7 @@ "name": "standards.DisableSharePointLegacyAuth", "cat": "SharePoint Standards", "tag": [ - "CIS M365 6.0.1 (7.2.1)", + "CIS M365 7.0.0 (7.2.1)", "spo_legacy_auth", "CISA (MS.AAD.3.1v1)", "NIST CSF 2.0 (PR.IR-01)" @@ -5167,8 +5168,8 @@ "name": "standards.sharingCapability", "cat": "SharePoint Standards", "tag": [ - "CIS M365 6.0.1 (7.2.3)", - "CIS M365 6.0.1 (7.2.4)", + "CIS M365 7.0.0 (7.2.3)", + "CIS M365 7.0.0 (7.2.4)", "CISA (MS.AAD.14.1v1)", "CISA (MS.SPO.1.1v1)" ], @@ -5219,7 +5220,7 @@ { "name": "standards.DisableReshare", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.5)", "CISA (MS.AAD.14.2v1)", "CISA (MS.SPO.1.2v1)"], + "tag": ["CIS M365 7.0.0 (7.2.5)", "CISA (MS.AAD.14.2v1)", "CISA (MS.SPO.1.2v1)"], "appliesToTest": ["CIS_7_2_5", "ZTNA21803", "ZTNA21804"], "helpText": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access", "docsDescription": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access. This is a tenant wide setting and overrules any settings set on the site level", @@ -5317,8 +5318,8 @@ { "name": "standards.unmanagedSync", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.3.2)", "CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"], - "appliesToTest": ["CIS_7_3_2", "ZTNA24824"], + "tag": ["CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"], + "appliesToTest": ["ZTNA24824"], "helpText": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect.", "docsDescription": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect. 0 = Allow Access, 1 = Allow limited, web-only access, 2 = Block access. All information about this can be found in Microsofts documentation [here.](https://learn.microsoft.com/en-us/sharepoint/control-access-from-unmanaged-devices)", "executiveText": "Restricts access to company files from personal or unmanaged devices, ensuring corporate data can only be accessed from properly secured and monitored devices. This critical security control prevents data leaks while allowing controlled access through web browsers when necessary.", @@ -5347,7 +5348,7 @@ { "name": "standards.sharingDomainRestriction", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.6)", "CISA (MS.AAD.14.3v1)", "CISA (MS.SPO.1.3v1)"], + "tag": ["CIS M365 7.0.0 (7.2.6)", "CISA (MS.AAD.14.3v1)", "CISA (MS.SPO.1.3v1)"], "appliesToTest": ["CIS_7_2_6", "ZTNA21803", "ZTNA21804"], "helpText": "Restricts sharing to only users with the specified domain. This is useful for organizations that only want to share with their own domain.", "executiveText": "Controls which external domains employees can share files with, enabling secure collaboration with trusted partners while blocking sharing with unauthorized organizations. This targeted approach maintains necessary business relationships while preventing data exposure to unknown entities.", @@ -5389,15 +5390,15 @@ "name": "standards.TeamsGlobalMeetingPolicy", "cat": "Teams Standards", "tag": [ - "CIS M365 6.0.1 (8.5.1)", - "CIS M365 6.0.1 (8.5.2)", - "CIS M365 6.0.1 (8.5.3)", - "CIS M365 6.0.1 (8.5.4)", - "CIS M365 6.0.1 (8.5.5)", - "CIS M365 6.0.1 (8.5.6)", - "CIS M365 6.0.1 (8.5.7)", - "CIS M365 6.0.1 (8.5.8)", - "CIS M365 6.0.1 (8.5.9)" + "CIS M365 7.0.0 (8.5.1)", + "CIS M365 7.0.0 (8.5.2)", + "CIS M365 7.0.0 (8.5.3)", + "CIS M365 7.0.0 (8.5.4)", + "CIS M365 7.0.0 (8.5.5)", + "CIS M365 7.0.0 (8.5.6)", + "CIS M365 7.0.0 (8.5.7)", + "CIS M365 7.0.0 (8.5.8)", + "CIS M365 7.0.0 (8.5.9)" ], "appliesToTest": [ "CIS_8_5_1", @@ -5531,7 +5532,7 @@ { "name": "standards.TeamsExternalChatWithAnyone", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.2.3)"], + "tag": ["CIS M365 7.0.0 (8.2.3)"], "appliesToTest": ["CIS_8_2_3"], "helpText": "Controls whether users can start Teams chats with any email address, inviting external recipients as guests via email.", "docsDescription": "Manages the Teams messaging policy setting UseB2BInvitesToAddExternalUsers. When enabled, users can start chats with any email address and recipients receive an invitation to join the chat as guests. Disabling the setting prevents these external email chats from being created, keeping conversations limited to internal users and approved guests.", @@ -5575,7 +5576,7 @@ "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowEmailIntoChannel $false", "recommendedBy": ["CIS"], - "tag": ["CIS M365 6.0.1 (8.1.2)"], + "tag": ["CIS M365 7.0.0 (8.1.2)"], "appliesToTest": ["CIS_8_1_2"], "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, @@ -5635,7 +5636,7 @@ { "name": "standards.TeamsExternalFileSharing", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.1.1)"], + "tag": ["CIS M365 7.0.0 (8.1.1)"], "appliesToTest": ["CIS_8_1_1"], "helpText": "Ensure external file sharing in Teams is enabled for only approved cloud storage services.", "executiveText": "Controls which external cloud storage services (like Google Drive, Dropbox, Box) employees can access through Teams, ensuring file sharing occurs only through approved and secure platforms. This helps maintain data governance while supporting necessary business integrations.", @@ -5706,7 +5707,7 @@ { "name": "standards.TeamsExternalAccessPolicy", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.2.1)", "CIS M365 6.0.1 (8.2.2)"], + "tag": ["CIS M365 7.0.0 (8.2.1)", "CIS M365 7.0.0 (8.2.2)"], "appliesToTest": ["CIS_8_2_1", "CIS_8_2_2"], "helpText": "Sets the properties of the Global external access policy.", "docsDescription": "Sets the properties of the Global external access policy. External access policies determine whether or not your users can: 1) communicate with users who have Session Initiation Protocol (SIP) accounts with a federated organization; 2) communicate with users who are using custom applications built with Azure Communication Services; 3) access Skype for Business Server over the Internet, without having to log on to your internal network; 4) communicate with users who have SIP accounts with a public instant messaging (IM) provider such as Skype; and, 5) communicate with people who are using Teams with an account that's not managed by an organization.", @@ -5734,7 +5735,7 @@ { "name": "standards.TeamsFederationConfiguration", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.2.1)"], + "tag": ["CIS M365 7.0.0 (8.2.1)"], "appliesToTest": ["CIS_8_2_1", "CIS_8_2_4"], "helpText": "Sets the properties of the Global federation configuration.", "docsDescription": "Sets the properties of the Global federation configuration. Federation configuration settings determine whether or not your users can communicate with users who have SIP accounts with a federated organization.", @@ -5810,7 +5811,7 @@ { "name": "standards.TeamsMessagingPolicy", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.6.1)"], + "tag": ["CIS M365 7.0.0 (8.6.1)"], "appliesToTest": ["CIS_8_6_1"], "helpText": "Sets the properties of the Global messaging policy.", "docsDescription": "Sets the properties of the Global messaging policy. Messaging policies control which chat and channel messaging features are available to users in Teams.", @@ -6405,18 +6406,18 @@ "impact": "High Impact", "addedDate": "2023-12-30", "tag": [ - "CIS M365 6.0.1 (5.2.2.1)", - "CIS M365 6.0.1 (5.2.2.2)", - "CIS M365 6.0.1 (5.2.2.3)", - "CIS M365 6.0.1 (5.2.2.4)", - "CIS M365 6.0.1 (5.2.2.5)", - "CIS M365 6.0.1 (5.2.2.6)", - "CIS M365 6.0.1 (5.2.2.7)", - "CIS M365 6.0.1 (5.2.2.8)", - "CIS M365 6.0.1 (5.2.2.9)", - "CIS M365 6.0.1 (5.2.2.10)", - "CIS M365 6.0.1 (5.2.2.11)", - "CIS M365 6.0.1 (5.2.2.12)", + "CIS M365 7.0.0 (5.2.2.1)", + "CIS M365 7.0.0 (5.2.2.2)", + "CIS M365 7.0.0 (5.2.2.3)", + "CIS M365 7.0.0 (5.2.2.4)", + "CIS M365 7.0.0 (5.2.2.5)", + "CIS M365 7.0.0 (5.2.2.6)", + "CIS M365 7.0.0 (5.2.2.7)", + "CIS M365 7.0.0 (5.2.2.8)", + "CIS M365 7.0.0 (5.2.2.9)", + "CIS M365 7.0.0 (5.2.2.10)", + "CIS M365 7.0.0 (5.2.2.11)", + "CIS M365 7.0.0 (5.2.2.12)", "SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.8)", @@ -6543,8 +6544,8 @@ "label": "Group Template", "multi": true, "cat": "Templates", - "tag": ["CIS M365 6.0.1 (5.1.3.1)"], - "appliesToTest": ["CIS_5_1_3_1"], + "tag": [], + "appliesToTest": [], "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2023-12-30", @@ -7467,10 +7468,10 @@ { "name": "standards.EnforcePrivateGroups", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (1.2.1)"], + "tag": ["CIS M365 7.0.0 (1.2.1)"], "appliesToTest": ["CIS_1_2_1"], "helpText": "Sets all public Microsoft 365 groups to private automatically. Groups can be excluded by display name keyword.", - "docsDescription": "Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 6.0.1 benchmark control 1.2.1.", + "docsDescription": "Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 7.0.0 benchmark control 1.2.1.", "executiveText": "Enforces private visibility on all Microsoft 365 groups to prevent unauthorised external access to group resources such as Teams, SharePoint sites, and Planner boards. Approved public groups can be excluded by name, ensuring governance while retaining flexibility for intentionally public collaboration spaces.", "addedComponent": [ { @@ -7501,7 +7502,7 @@ { "name": "standards.EmptyFilterIPAllowList", "cat": "Defender Standards", - "tag": ["CIS M365 6.0.1 (2.1.12)"], + "tag": ["CIS M365 7.0.0 (2.1.12)"], "appliesToTest": ["CIS_2_1_12"], "helpText": "Ensures the connection filter IP allow list is not used. IPs on this list bypass spam, spoof, and authentication checks.", "docsDescription": "IPs on the connection filter allow list bypass spam, spoof, and authentication checks. CIS recommends keeping this list empty to ensure all inbound email is properly scanned. This standard checks that the IPAllowList on the Default hosted connection filter policy is empty and can remediate by clearing it.", @@ -7524,10 +7525,10 @@ { "name": "standards.TeamsZAP", "cat": "Defender Standards", - "tag": ["CIS M365 6.0.1 (2.4.4)"], + "tag": ["CIS M365 7.0.0 (2.4.4)"], "appliesToTest": ["CIS_2_4_4"], "helpText": "Ensures Zero-hour auto purge (ZAP) is enabled for Microsoft Teams, automatically removing malicious messages after delivery.", - "docsDescription": "Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 6.0.1 benchmark control 2.4.4.", + "docsDescription": "Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 7.0.0 benchmark control 2.4.4.", "executiveText": "Enables Zero-hour auto purge for Microsoft Teams to automatically detect and remove malicious messages after delivery. This provides an additional layer of protection against phishing and malware that may bypass initial scanning, ensuring threats are neutralised even after they reach users.", "addedComponent": [], "label": "Ensure Zero-hour auto purge for Microsoft Teams is on", @@ -7547,7 +7548,7 @@ { "name": "standards.CollaborationDomainRestriction", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.6.1)"], + "tag": ["CIS M365 7.0.0 (5.1.6.1)"], "appliesToTest": ["CIS_5_1_6_1"], "helpText": "Restricts B2B collaboration invitations to a specified list of allowed domains. If no domains are provided, the standard will alert and report on whether any domain restrictions are currently configured.", "docsDescription": "By default, Microsoft Entra ID allows collaboration invitations to be sent to any external domain. CIS recommends restricting B2B collaboration invitations to only approved domains to reduce the risk of data exfiltration and unauthorized access. This standard checks the B2B management policy for an allow list of domains and can remediate by setting the allowed domains list.", @@ -7897,7 +7898,8 @@ { "name": "standards.SmartLockout", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (5.2.3.8)", "CIS M365 7.0.0 (5.2.3.9)"], + "appliesToTest": ["CIS_5_2_3_8", "CIS_5_2_3_9"], "helpText": "**Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode.", "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced).", "addedComponent": [ From 4c2843c74331a4af685b700ab82f20b4cdbe33b7 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:24:08 +0800 Subject: [PATCH 160/164] more secure pipeline configuration --- .npmrc | 26 ++++++++++++++++++++++++++ .yarnrc | 18 ++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 .npmrc create mode 100644 .yarnrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000000..12937d4c275f --- /dev/null +++ b/.npmrc @@ -0,0 +1,26 @@ +# Supply-chain hardening for CIPP +# This file is honored by BOTH npm and yarn (yarn classic reads .npmrc). +# Any change here should be reviewed for CI/CD impact. + +# Refuse to execute package lifecycle scripts (pre/postinstall, prepare, etc.) +# on dependency install. CIPP has zero of its own lifecycle scripts in +# package.json, so the only scripts this would block are from third-party +# packages — exactly the attack surface we want to close. +ignore-scripts=true + +# Pin the registry explicitly so an inherited per-user .npmrc cannot redirect +# CI / contributor installs to a malicious mirror. +registry=https://registry.npmjs.org/ + +# Require integrity hashes (sha512) to match the lockfile on install. +# npm honors this directly; yarn classic always verifies lockfile integrity +# but this makes the intent explicit. +audit-level=high + +# Don't auto-save changes to the lockfile from arbitrary install commands. +# Lockfile edits should only happen via Dependabot PRs or explicit upgrades. +save-exact=true + +# Disable funding/notifier noise so CI logs only show real signal. +fund=false +update-notifier=false diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 000000000000..c0a88a3c68ee --- /dev/null +++ b/.yarnrc @@ -0,0 +1,18 @@ +# Supply-chain hardening for CIPP (yarn 1 / classic) +# +# This complements .npmrc — yarn 1 honors `ignore-scripts` from .npmrc, but +# we set the per-command equivalents here as defense in depth so the +# protection survives even if .npmrc is missing or ignored. + +# Refuse to execute lifecycle scripts on `yarn install` / `yarn add` / +# `yarn upgrade`. Mirrors `ignore-scripts=true` in .npmrc. +--install.ignore-scripts true +--add.ignore-scripts true +--upgrade.ignore-scripts true + +# Pin the registry so a poisoned per-user .yarnrc cannot redirect installs. +registry "https://registry.npmjs.org/" + +# Disable yarn's self-update check — CI should never auto-update its own +# yarn binary mid-build. +disable-self-update-check true From 55e8eef2399713f966791554ee214f928cb72f81 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:06:59 +0800 Subject: [PATCH 161/164] Update worker-health.js --- src/pages/cipp/advanced/worker-health.js | 84 +++++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/src/pages/cipp/advanced/worker-health.js b/src/pages/cipp/advanced/worker-health.js index e22ed9f8b31d..29d8530cbe9e 100644 --- a/src/pages/cipp/advanced/worker-health.js +++ b/src/pages/cipp/advanced/worker-health.js @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useMemo, useRef, useState } from "react"; import Head from "next/head"; import { Box, @@ -1050,12 +1050,45 @@ const Page = () => { const diag = cacheDiagQuery.data?.Results; if (!diag) return null; const types = diag.TypeBreakdown ?? []; + const trackedMB = diag.TrackedTotalMB ?? 0; + const maxMB = diag.MaxMB ?? 0; + const memPct = maxMB > 0 ? (trackedMB / maxMB) * 100 : 0; + const totalReads = (diag.Hits ?? 0) + (diag.Misses ?? 0); + const fmtUtc = (s) => (s ? new Date(s).toLocaleString() : "—"); + + const cacheStats = [ + { k: "Hits", v: (diag.Hits ?? 0).toLocaleString() }, + { k: "Misses", v: (diag.Misses ?? 0).toLocaleString() }, + { + k: "Hit Rate", + v: `${diag.HitRate ?? 0}%`, + w: totalReads > 100 && diag.HitRate < 50, + }, + { + k: "Evictions", + v: (diag.Evictions ?? 0).toLocaleString(), + w: (diag.Evictions ?? 0) > 0, + }, + { + k: "Oversized", + v: (diag.Oversized ?? 0).toLocaleString(), + w: (diag.Oversized ?? 0) > 0, + tip: "Values that exceeded the per-entry size cap and were silently dropped — they were never cached.", + }, + { k: "Accesses", v: (diag.AccessCount ?? 0).toLocaleString() }, + { + k: "TTL", + v: `${diag.TtlSeconds ?? 0}s`, + tip: `Earliest expiry: ${fmtUtc(diag.EarliestExpiryUtc)} • Latest expiry: ${fmtUtc(diag.LatestExpiryUtc)}`, + }, + ]; + return ( { /> } /> + 0 ? 2 : "12px !important" }}> + {/* Capacity bar */} + + + + Capacity + + + {trackedMB} / {maxMB} MB ({Math.round(memPct)}%) + + + 85 ? "error" : memPct > 70 ? "warning" : "primary"} + sx={{ height: 6, borderRadius: 3 }} + /> + + {/* Stats row */} + + {cacheStats.map((s) => { + const cell = ( + + + {s.k} + + + {s.v} + + + ); + if (s.tip) { + return ( + + {cell} + + ); + } + return {cell}; + })} + + {types.length > 0 && ( From 4c0c058f42dcd7cdb23fb83d64df0884a38493c4 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:28:09 +0800 Subject: [PATCH 162/164] Update alerts.json --- src/data/alerts.json | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/data/alerts.json b/src/data/alerts.json index ac8a28fa6cc0..e0f7ffc9b9a8 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -122,9 +122,19 @@ "name": "QuotaUsed", "label": "Alert on % mailbox quota used", "requiresInput": true, - "inputType": "textField", - "inputLabel": "Enter quota percentage", - "inputName": "QuotaUsedQuota", + "multipleInput": true, + "inputs": [ + { + "inputType": "textField", + "inputLabel": "Quota percentage threshold (default: 90)", + "inputName": "QuotaUsedQuota" + }, + { + "inputType": "textField", + "inputLabel": "Excluded mailbox UPNs (comma-separated). Supports custom variables like %excludefrommailboxalert% defined under CIPP > Settings > Custom Variables at tenant or AllTenants scope.", + "inputName": "QuotaUsedExcludedMailboxes" + } + ], "recommendedRunInterval": "1d" }, { From 4df01a60857ef84b8cf7085ddc1684aabc135767 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:16:54 +0800 Subject: [PATCH 163/164] Update unauthenticated.js --- src/pages/unauthenticated.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/pages/unauthenticated.js b/src/pages/unauthenticated.js index 6bf4cb995902..49c6d26861b6 100644 --- a/src/pages/unauthenticated.js +++ b/src/pages/unauthenticated.js @@ -26,6 +26,9 @@ const Page = () => { } return []; }, [orgData.isSuccess, orgData.data?.clientPrincipal?.userRoles]); + + const canReturnHome = + swaStatus.isSuccess && !!swaStatus?.data?.clientPrincipal && userRoles.length > 0; return ( <> @@ -57,13 +60,9 @@ const Page = () => { "You're not allowed to be here, or are logged in under the wrong account." } title="Access Denied" - linkText={ - swaStatus?.data?.clientPrincipal !== null && userRoles.length > 0 - ? "Return to Home" - : "Login" - } + linkText={canReturnHome ? "Return to Home" : "Login"} link={ - swaStatus?.data?.clientPrincipal !== null && userRoles.length > 0 + canReturnHome ? "/" : `/.auth/login/aad?prompt=select_account&post_login_redirect_uri=${encodeURIComponent( window.location.href From 9c82a214263d290a46901fb7f4e804b4bfb573b5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:32:48 +0200 Subject: [PATCH 164/164] 10.5.0 version up --- package.json | 2 +- public/version.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 331476b49fa7..45e4b83f482d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.4.5", + "version": "10.5.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/public/version.json b/public/version.json index a09d0fcf2ccd..b7e5a36d3a96 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.4.5" + "version": "10.5.0" }