---
package.json | 2 +-
yarn.lock | 18 +++++++++---------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/package.json b/package.json
index f1445743ec47..176c5ccd2535 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/query-sync-storage-persister": "^5.90.25",
"@tanstack/react-query": "^5.100.10",
- "@tanstack/react-query-devtools": "^5.96.2",
+ "@tanstack/react-query-devtools": "^5.100.10",
"@tanstack/react-query-persist-client": "^5.96.2",
"@tanstack/react-table": "^8.19.2",
"@tiptap/core": "^3.22.3",
diff --git a/yarn.lock b/yarn.lock
index c8dbd8c06a1a..c5c24c97e45d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2079,10 +2079,10 @@
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.96.2.tgz#766dab253476afd0b27959b66abb606d8d2dd9f5"
integrity sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==
-"@tanstack/query-devtools@5.96.2":
- version "5.96.2"
- resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.96.2.tgz#6301662b95d4a7a8b9b53e53d3a9091ae45e4d25"
- integrity sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g==
+"@tanstack/query-devtools@5.100.10":
+ version "5.100.10"
+ resolved "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.10.tgz#1972789fdc7c4cb9ec2062d51f25bc4dc655a27b"
+ integrity sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw==
"@tanstack/query-persist-client-core@5.92.4":
version "5.92.4"
@@ -2106,12 +2106,12 @@
"@tanstack/query-core" "5.91.2"
"@tanstack/query-persist-client-core" "5.92.4"
-"@tanstack/react-query-devtools@^5.96.2":
- version "5.96.2"
- resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.96.2.tgz#b44a19f1ebdb45a3e59fcf4e4361ff37500f382c"
- integrity sha512-nTFKLGuTOFvmFRvcyZ3ArWC/DnMNPoBh6h/2yD6rsf7TCTJCQt+oUWOp2uKPTIuEPtF/vN9Kw5tl5mD1Kbposw==
+"@tanstack/react-query-devtools@^5.100.10":
+ version "5.100.10"
+ resolved "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.10.tgz#cca3479cc2c8b434637c31f8119fe6ff93e5832c"
+ integrity sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg==
dependencies:
- "@tanstack/query-devtools" "5.96.2"
+ "@tanstack/query-devtools" "5.100.10"
"@tanstack/react-query-persist-client@^5.96.2":
version "5.96.2"
From 66c584b70b1bd130496d47c8732c7083caa813dd Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Thu, 11 Jun 2026 14:41:48 +0800
Subject: [PATCH 10/34] Update function-offloading.js
---
src/pages/cipp/advanced/super-admin/function-offloading.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/pages/cipp/advanced/super-admin/function-offloading.js b/src/pages/cipp/advanced/super-admin/function-offloading.js
index 55d812b60fde..d9bb3fd63207 100644
--- a/src/pages/cipp/advanced/super-admin/function-offloading.js
+++ b/src/pages/cipp/advanced/super-admin/function-offloading.js
@@ -109,6 +109,7 @@ const Page = () => {
confirmText:
"Are you sure you want to delete the offloaded function entry for [Name]? This does not delete the function app from Azure, this must be done first or it will register again.",
condition: (row) => row.Default !== true,
+ hideBulk: true,
},
]}
tableFilter={
From 4c0afcfb35b2c68fc26ab0ced225a8f89513b100 Mon Sep 17 00:00:00 2001
From: Bobby <31723128+kris6673@users.noreply.github.com>
Date: Thu, 11 Jun 2026 11:59:13 +0200
Subject: [PATCH 11/34] feat: add clone policy action to Intune policy
management
---
.../CippIntunePolicyActions.jsx | 45 ++++++++++++++++++-
1 file changed, 44 insertions(+), 1 deletion(-)
diff --git a/src/components/CippComponents/CippIntunePolicyActions.jsx b/src/components/CippComponents/CippIntunePolicyActions.jsx
index 09faf1efebf5..2a6396173a7a 100644
--- a/src/components/CippComponents/CippIntunePolicyActions.jsx
+++ b/src/components/CippComponents/CippIntunePolicyActions.jsx
@@ -1,5 +1,11 @@
import { Book, LaptopChromebook } from '@mui/icons-material'
-import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from '@heroicons/react/24/outline'
+import {
+ DocumentDuplicateIcon,
+ GlobeAltIcon,
+ TrashIcon,
+ UserIcon,
+ UserGroupIcon,
+} from '@heroicons/react/24/outline'
const assignmentModeOptions = [
{ label: 'Replace existing assignments', value: 'replace' },
@@ -18,6 +24,7 @@ const assignmentFilterTypeOptions = [
* @param {object} options - Additional options
* @param {string} options.platformType - Platform type for app protection policies (deviceAppManagement)
* @param {boolean} options.includeCreateTemplate - Whether to include create template action (default: true)
+ * @param {boolean} options.includeClone - Whether to include the clone policy action (default: true)
* @param {boolean} options.includeDelete - Whether to include delete action (default: true)
* @param {string} options.deleteUrlName - URLName for delete action (default: same as policyType)
* @param {object} options.templateData - Data for template creation
@@ -27,6 +34,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
const {
platformType = null,
includeCreateTemplate = true,
+ includeClone = true,
includeDelete = true,
deleteUrlName = policyType,
templateData = null,
@@ -125,6 +133,41 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
})
}
+ // Clone policy action
+ if (includeClone) {
+ actions.push({
+ label: 'Clone Policy',
+ type: 'POST',
+ url: '/api/AddIntunePolicyClone',
+ multiPost: false,
+ icon: ,
+ color: 'info',
+ data: templateData || {
+ ID: 'id',
+ URLName: policyType === 'URLName' ? 'URLName' : policyType,
+ },
+ fields: [
+ {
+ type: 'textField',
+ name: 'newDisplayName',
+ label: 'New Display Name',
+ validators: { required: 'Please enter a name for the cloned policy' },
+ },
+ {
+ type: 'textField',
+ name: 'newDescription',
+ label: 'Description',
+ },
+ ],
+ defaultvalues: (row) => ({
+ newDisplayName: row?.displayName ? `${row.displayName} - Copy` : '',
+ newDescription: row?.description ?? '',
+ }),
+ confirmText:
+ 'Enter a name for the cloned policy. The name must be different from the original policy and assignments are not copied to the clone.',
+ })
+ }
+
// Assign to All Users
actions.push({
label: 'Assign to All Users',
From cd773184c11ce4048f64a0554cf5d2b2e0f532ae Mon Sep 17 00:00:00 2001
From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com>
Date: Thu, 11 Jun 2026 13:19:19 +0200
Subject: [PATCH 12/34] copilot menu and items
---
.../CippComponents/CippTranslations.jsx | 7 +
src/data/standards.json | 126 ++++++++-
src/layouts/config.js | 62 ++++-
src/pages/copilot/agent365/packages/index.js | 79 ++++++
.../copilot/reports/copilot-adoption/index.js | 17 ++
.../copilot/reports/copilot-trend/index.js | 29 ++
.../copilot/reports/copilot-usage/index.js | 29 ++
src/pages/copilot/settings/index.js | 49 ++++
src/pages/copilot/shadow-ai/index.js | 248 ++++++++++++++++++
9 files changed, 643 insertions(+), 3 deletions(-)
create mode 100644 src/pages/copilot/agent365/packages/index.js
create mode 100644 src/pages/copilot/reports/copilot-adoption/index.js
create mode 100644 src/pages/copilot/reports/copilot-trend/index.js
create mode 100644 src/pages/copilot/reports/copilot-usage/index.js
create mode 100644 src/pages/copilot/settings/index.js
create mode 100644 src/pages/copilot/shadow-ai/index.js
diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx
index 5975edd0b13a..2f83966b3c5d 100644
--- a/src/components/CippComponents/CippTranslations.jsx
+++ b/src/components/CippComponents/CippTranslations.jsx
@@ -1,5 +1,12 @@
export const CippTranslations = {
userPrincipalName: 'User Principal Name',
+ aiTool: 'AI Tool',
+ applicationId: 'Application ID',
+ signInsLast7Days: 'Sign-ins (7 Days)',
+ signIns: 'Sign-ins',
+ activeUsersLast7Days: 'Active Users (7 Days)',
+ firstConsentedDateTime: 'First Consented',
+ deviceCount: 'Devices',
displayName: 'Display Name',
mail: 'Mail',
mobilePhone: 'Mobile Phone',
diff --git a/src/data/standards.json b/src/data/standards.json
index d0fdad020394..9139734f7aa1 100644
--- a/src/data/standards.json
+++ b/src/data/standards.json
@@ -1,4 +1,113 @@
[
+ {
+ "name": "standards.CopilotSettings",
+ "cat": "Copilot (M365) Standards",
+ "tag": [],
+ "helpText": "Configures Microsoft 365 Copilot tenant policy settings: Copilot Chat pinning, blocking Copilot access to open content, Designer image generation, web search, and admin-center Copilot. Each setting can be left unconfigured, enabled, or disabled. These settings are managed through the Copilot policy service (Cloud Policy / Intune) and are applied at the tenant level.",
+ "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. The exact accepted value per setting is a string (commonly \"1\"/\"0\") and should be validated against a Copilot-licensed tenant.",
+ "executiveText": "Provides centralized governance of Microsoft 365 Copilot capabilities across the organization. Administrators can control whether Copilot Chat is pinned for users, whether Copilot can access open files, and whether features such as image generation and web search are available, helping balance employee productivity with data governance and compliance requirements.",
+ "addedComponent": [
+ {
+ "type": "autoComplete",
+ "multiple": false,
+ "creatable": false,
+ "label": "Pin Microsoft 365 Copilot Chat",
+ "name": "standards.CopilotSettings.copilotChatPinning",
+ "options": [
+ { "label": "Do not configure", "value": "donotconfigure" },
+ { "label": "Enabled", "value": "1" },
+ { "label": "Disabled", "value": "0" }
+ ]
+ },
+ {
+ "type": "autoComplete",
+ "multiple": false,
+ "creatable": false,
+ "label": "Copilot Access to Open Content",
+ "name": "standards.CopilotSettings.blockAccessToOpenFiles",
+ "options": [
+ { "label": "Do not configure", "value": "donotconfigure" },
+ { "label": "Block open content", "value": "1" },
+ { "label": "Allow open content", "value": "0" }
+ ]
+ },
+ {
+ "type": "autoComplete",
+ "multiple": false,
+ "creatable": false,
+ "label": "Designer Image Generation",
+ "name": "standards.CopilotSettings.imageGeneration",
+ "options": [
+ { "label": "Do not configure", "value": "donotconfigure" },
+ { "label": "Enabled", "value": "1" },
+ { "label": "Disabled", "value": "0" }
+ ]
+ },
+ {
+ "type": "autoComplete",
+ "multiple": false,
+ "creatable": false,
+ "label": "Web Search in Copilot",
+ "name": "standards.CopilotSettings.allowWebSearch",
+ "options": [
+ { "label": "Do not configure", "value": "donotconfigure" },
+ { "label": "Enabled", "value": "1" },
+ { "label": "Disabled", "value": "0" }
+ ]
+ },
+ {
+ "type": "autoComplete",
+ "multiple": false,
+ "creatable": false,
+ "label": "Admin Copilot in Microsoft 365 Admin Center",
+ "name": "standards.CopilotSettings.allowInAdminCenters",
+ "options": [
+ { "label": "Do not configure", "value": "donotconfigure" },
+ { "label": "Enabled", "value": "1" },
+ { "label": "Disabled", "value": "0" }
+ ]
+ }
+ ],
+ "label": "Configure Microsoft 365 Copilot policy settings",
+ "impact": "Medium Impact",
+ "impactColour": "warning",
+ "addedDate": "2026-06-09",
+ "powershellEquivalent": "Graph API: PATCH /beta/copilot/admin/policySettings/{id}",
+ "recommendedBy": []
+ },
+ {
+ "name": "standards.CopilotLimitedMode",
+ "cat": "Copilot (M365) Standards",
+ "tag": [],
+ "helpText": "Controls Microsoft 365 Copilot Limited Mode for Teams meetings. When enabled for a group, Copilot in Teams meetings does not respond to sentiment-related prompts (inferring emotions, behavior, or judgments) for members of the selected group. A target group is required when enabling. Managed via the Copilot admin settings Graph API.",
+ "docsDescription": "Configures the `copilotAdminLimitedMode` setting through the `/copilot/admin/settings/limitedMode` Microsoft Graph API (beta). When enabled, `isEnabledForGroup` is set to true and applied to the resolved target group; when disabled, `isEnabledForGroup` is set to false. NOTE: this API currently requires delegated authentication and the acting identity must be Global Administrator to write the setting.",
+ "executiveText": "Limits Microsoft 365 Copilot in Teams meetings so it does not provide opinions on sentiment, emotions, or judgments for a selected group of users. This helps organizations meet workplace policy, privacy, and works-council requirements while still allowing Copilot to summarize and answer factual questions grounded in the meeting.",
+ "addedComponent": [
+ {
+ "type": "switch",
+ "name": "standards.CopilotLimitedMode.LimitedModeEnabled",
+ "label": "Enable Copilot Limited Mode for a group (Teams meetings)",
+ "defaultValue": false
+ },
+ {
+ "type": "textField",
+ "name": "standards.CopilotLimitedMode.GroupName",
+ "label": "Target Group Name (wildcard match; required when enabled)",
+ "required": false,
+ "condition": {
+ "field": "standards.CopilotLimitedMode.LimitedModeEnabled",
+ "compareType": "is",
+ "compareValue": true
+ }
+ }
+ ],
+ "label": "Configure Microsoft 365 Copilot Limited Mode (Teams meetings)",
+ "impact": "Medium Impact",
+ "impactColour": "warning",
+ "addedDate": "2026-06-09",
+ "powershellEquivalent": "Graph API: PATCH /beta/copilot/admin/settings/limitedMode",
+ "recommendedBy": []
+ },
{
"name": "standards.MailContacts",
"cat": "Global Standards",
@@ -1790,8 +1899,21 @@
{
"name": "standards.AppManagementPolicy",
"cat": "Entra (AAD) Standards",
- "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", "ZTNA21773", "ZTNA21896", "ZTNA21992"],
+ "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",
+ "ZTNA21773",
+ "ZTNA21896",
+ "ZTNA21992"
+ ],
"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.",
diff --git a/src/layouts/config.js b/src/layouts/config.js
index 7a41ee8ca57f..2556368447c7 100644
--- a/src/layouts/config.js
+++ b/src/layouts/config.js
@@ -1,4 +1,10 @@
-import { BuildingOfficeIcon, HomeIcon, UsersIcon, WrenchIcon } from '@heroicons/react/24/outline'
+import {
+ BuildingOfficeIcon,
+ HomeIcon,
+ SparklesIcon,
+ UsersIcon,
+ WrenchIcon,
+} from '@heroicons/react/24/outline'
import {
CloudOutlined,
HomeRepairService,
@@ -444,6 +450,60 @@ export const nativeMenuItems = [
},
],
},
+ {
+ title: 'Copilot & AI',
+ type: 'header',
+ icon: (
+
+
+
+ ),
+ permissions: ['Tenant.Standards.*'],
+ items: [
+ {
+ title: 'Shadow AI Discovery',
+ path: '/copilot/shadow-ai',
+ permissions: ['Tenant.Standards.*'],
+ },
+ {
+ title: 'Copilot Settings',
+ path: '/copilot/settings',
+ permissions: ['Tenant.Standards.*'],
+ },
+ {
+ title: 'Agent365',
+ permissions: ['Tenant.Standards.*'],
+ items: [
+ {
+ title: 'Packages',
+ path: '/copilot/agent365/packages',
+ permissions: ['Tenant.Standards.*'],
+ },
+ ],
+ },
+ {
+ title: 'Reports',
+ permissions: ['Tenant.Standards.*'],
+ items: [
+ {
+ title: 'Copilot Adoption',
+ path: '/copilot/reports/copilot-adoption',
+ permissions: ['Tenant.Standards.*'],
+ },
+ {
+ title: 'Copilot Usage Trend',
+ path: '/copilot/reports/copilot-trend',
+ permissions: ['Tenant.Standards.*'],
+ },
+ {
+ title: 'Copilot User Activity',
+ path: '/copilot/reports/copilot-usage',
+ permissions: ['Tenant.Standards.*'],
+ },
+ ],
+ },
+ ],
+ },
{
title: 'Intune',
type: 'header',
diff --git a/src/pages/copilot/agent365/packages/index.js b/src/pages/copilot/agent365/packages/index.js
new file mode 100644
index 000000000000..d2c8d9208614
--- /dev/null
+++ b/src/pages/copilot/agent365/packages/index.js
@@ -0,0 +1,79 @@
+import { Layout as DashboardLayout } from '../../../../layouts/index.js'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import CippJsonView from '../../../../components/CippFormPages/CippJSONView'
+import { ApiGetCall } from '../../../../api/ApiCall'
+import { useSettings } from '../../../../hooks/use-settings'
+import { Box, Skeleton, Typography } from '@mui/material'
+
+// Drill-in panel: the list omits allowedUsersAndGroups / acquireUsersAndGroups / elementDetails,
+// so fetch the per-package detail (delegated GET /packages/{id}) when a row is opened.
+const PackageDetailPanel = ({ row }) => {
+ const currentTenant = useSettings().currentTenant
+ const detail = ApiGetCall({
+ url: '/api/ListAgent365PackageDetail',
+ data: { id: row?.id, tenantFilter: currentTenant },
+ queryKey: `Agent365PackageDetail-${currentTenant}-${row?.id}`,
+ waiting: !!row?.id,
+ })
+
+ return (
+
+
+ Package detail (allowed / acquired users & groups, elements)
+
+ {detail.isFetching && }
+ {detail.isSuccess && }
+
+ )
+}
+
+const Page = () => {
+ const simpleColumns = [
+ 'displayName',
+ 'type',
+ 'publisher',
+ 'version',
+ 'supportedHosts',
+ 'elementTypes',
+ 'availableTo',
+ 'deployedTo',
+ 'isBlocked',
+ 'lastModifiedDateTime',
+ ]
+
+ const offCanvas = {
+ extendedInfoFields: [
+ 'id',
+ 'displayName',
+ 'type',
+ 'publisher',
+ 'version',
+ 'platform',
+ 'supportedHosts',
+ 'elementTypes',
+ 'availableTo',
+ 'deployedTo',
+ 'isBlocked',
+ 'manifestId',
+ 'manifestVersion',
+ 'appId',
+ 'assetId',
+ 'shortDescription',
+ 'lastModifiedDateTime',
+ ],
+ children: (row) => ,
+ }
+
+ return (
+
+ )
+}
+
+Page.getLayout = (page) => {page}
+
+export default Page
diff --git a/src/pages/copilot/reports/copilot-adoption/index.js b/src/pages/copilot/reports/copilot-adoption/index.js
new file mode 100644
index 000000000000..366871784203
--- /dev/null
+++ b/src/pages/copilot/reports/copilot-adoption/index.js
@@ -0,0 +1,17 @@
+import { Layout as DashboardLayout } from '../../../../layouts/index.js'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+
+const Page = () => {
+ return (
+
+ )
+}
+
+Page.getLayout = (page) => {page}
+
+export default Page
diff --git a/src/pages/copilot/reports/copilot-trend/index.js b/src/pages/copilot/reports/copilot-trend/index.js
new file mode 100644
index 000000000000..20a7994df2ed
--- /dev/null
+++ b/src/pages/copilot/reports/copilot-trend/index.js
@@ -0,0 +1,29 @@
+import { Layout as DashboardLayout } from '../../../../layouts/index.js'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+
+const Page = () => {
+ return (
+
+ )
+}
+
+Page.getLayout = (page) => {page}
+
+export default Page
diff --git a/src/pages/copilot/reports/copilot-usage/index.js b/src/pages/copilot/reports/copilot-usage/index.js
new file mode 100644
index 000000000000..46c8aa43c370
--- /dev/null
+++ b/src/pages/copilot/reports/copilot-usage/index.js
@@ -0,0 +1,29 @@
+import { Layout as DashboardLayout } from '../../../../layouts/index.js'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+
+const Page = () => {
+ return (
+
+ )
+}
+
+Page.getLayout = (page) => {page}
+
+export default Page
diff --git a/src/pages/copilot/settings/index.js b/src/pages/copilot/settings/index.js
new file mode 100644
index 000000000000..29d984013327
--- /dev/null
+++ b/src/pages/copilot/settings/index.js
@@ -0,0 +1,49 @@
+import { Layout as DashboardLayout } from '../../../layouts/index.js'
+import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx'
+import { useSettings } from '../../../hooks/use-settings'
+import { Cog6ToothIcon } from '@heroicons/react/24/outline'
+
+const Page = () => {
+ const currentTenant = useSettings().currentTenant
+ const queryKey = `ListCopilotSettings-${currentTenant}`
+
+ const actions = [
+ {
+ label: 'Set Status',
+ type: 'POST',
+ url: '/api/ExecCopilotSettings',
+ icon: ,
+ data: { settingId: 'settingId' },
+ fields: [
+ {
+ type: 'autoComplete',
+ name: 'value',
+ label: 'Desired state',
+ multiple: false,
+ creatable: false,
+ options: [
+ { label: 'Enabled', value: '1' },
+ { label: 'Disabled', value: '0' },
+ { label: 'Not configured', value: 'clear' },
+ ],
+ },
+ ],
+ confirmText: "Set '[setting]' to the selected state?",
+ relatedQueryKeys: [queryKey],
+ },
+ ]
+
+ return (
+
+ )
+}
+
+Page.getLayout = (page) => {page}
+
+export default Page
diff --git a/src/pages/copilot/shadow-ai/index.js b/src/pages/copilot/shadow-ai/index.js
new file mode 100644
index 000000000000..8c7626b41b20
--- /dev/null
+++ b/src/pages/copilot/shadow-ai/index.js
@@ -0,0 +1,248 @@
+import { Layout as DashboardLayout } from '../../../layouts/index.js'
+import { CippInfoBar } from '../../../components/CippCards/CippInfoBar'
+import { CippChartCard } from '../../../components/CippCards/CippChartCard'
+import { CippImageCard } from '../../../components/CippCards/CippImageCard'
+import { CippDataTable } from '../../../components/CippTable/CippDataTable'
+import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog'
+import { CippOffCanvas } from '../../../components/CippComponents/CippOffCanvas'
+import { useDialog } from '../../../hooks/use-dialog'
+import { ApiGetCall } from '../../../api/ApiCall'
+import { useSettings } from '../../../hooks/use-settings'
+import { Alert, Button, Container, Stack, SvgIcon, Typography } from '@mui/material'
+import { Grid } from '@mui/system'
+import {
+ ArrowPathIcon,
+ CpuChipIcon,
+ ComputerDesktopIcon,
+ KeyIcon,
+ ExclamationTriangleIcon,
+ UserGroupIcon,
+} from '@heroicons/react/24/outline'
+
+// Drawer listing the users who signed in to an AI application in the last 7 days.
+const ApplicationUsersDrawer = ({ row, drawerVisible, setDrawerVisible }) => (
+ setDrawerVisible(false)}
+ >
+
+
+)
+
+// Datasets in the CIPP reporting database this report is compiled from.
+const syncRows = [
+ { Name: 'DetectedApps' },
+ { Name: 'ServicePrincipals' },
+ { Name: 'OAuth2PermissionGrants' },
+]
+
+const Page = () => {
+ const currentTenant = useSettings().currentTenant
+ const syncDialog = useDialog()
+ const queryKey = `ListShadowAI-${currentTenant}`
+
+ const shadowAi = ApiGetCall({
+ url: '/api/ListShadowAI',
+ data: { tenantFilter: currentTenant },
+ queryKey: queryKey,
+ waiting: !!currentTenant && currentTenant !== 'AllTenants',
+ })
+
+ const data = shadowAi.data ?? {}
+ const summary = data.summary ?? {}
+ const byCategory = data.byCategory ?? []
+ const byRisk = data.byRisk ?? []
+ const topTools = data.topTools ?? []
+ const needsSync = shadowAi.isSuccess && !summary.intuneSynced && !summary.entraSynced
+ const showCharts = shadowAi.isFetching || byCategory.length > 0
+
+ return (
+
+
+ {currentTenant === 'AllTenants' ? (
+
+
+
+ ) : (
+ <>
+
+
+
+ {summary.lastDataRefresh
+ ? `Last data refresh: ${new Date(summary.lastDataRefresh).toLocaleString()}`
+ : ''}
+
+
+
+
+ }
+ >
+ Sync data
+
+
+ {needsSync && (
+
+ No cached data found for this tenant yet. Click "Sync data" to collect the Intune
+ and Entra datasets; the report populates once the sync completes.
+
+ )}
+
+
+ ,
+ name: 'AI Tools Detected',
+ data: `${summary.aiToolsDetected ?? 0}`,
+ },
+ {
+ icon: ,
+ name: 'Device Installs',
+ data: `${summary.deviceInstalls ?? 0}`,
+ },
+ {
+ icon: ,
+ name: 'AI Apps in Entra',
+ data: `${summary.consentedAiApps ?? 0}`,
+ },
+ {
+ icon: ,
+ name: 'High-Risk AI Tools',
+ data: `${summary.highRiskTools ?? 0}`,
+ color: 'error',
+ },
+ ]}
+ />
+
+
+ {showCharts && (
+ <>
+
+ item.category)}
+ chartSeries={byCategory.map((item) => item.tools)}
+ totalLabel="Tools"
+ />
+
+
+ item.tool)}
+ chartSeries={topTools.map((item) => item.footprint)}
+ totalLabel="Devices + Users"
+ />
+
+
+ item.risk)}
+ chartSeries={byRisk.map((item) => item.tools)}
+ totalLabel="Tools"
+ />
+
+ >
+ )}
+
+
+
+
+
+
+ ,
+ customComponent: (row, { drawerVisible, setDrawerVisible }) => (
+
+ ),
+ multiPost: false,
+ },
+ ]}
+ data={data.consentedApps ?? []}
+ simpleColumns={[
+ 'application',
+ 'aiTool',
+ 'category',
+ 'risk',
+ 'applicationId',
+ 'approvedPermissions',
+ 'signInsLast7Days',
+ 'activeUsersLast7Days',
+ 'firstConsentedDateTime',
+ ]}
+ />
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+Page.getLayout = (page) => {page}
+
+export default Page
From 482b15e2dbffdb7a39523cfe6cf3af8256987606 Mon Sep 17 00:00:00 2001
From: Bobby <31723128+kris6673@users.noreply.github.com>
Date: Thu, 11 Jun 2026 18:00:58 +0200
Subject: [PATCH 13/34] refactor: change default to private when creating teams
Fixes #6168
---
src/pages/teams-share/teams/list-team/add.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/pages/teams-share/teams/list-team/add.jsx b/src/pages/teams-share/teams/list-team/add.jsx
index fe625e628eea..bc2f64c4f3d2 100644
--- a/src/pages/teams-share/teams/list-team/add.jsx
+++ b/src/pages/teams-share/teams/list-team/add.jsx
@@ -17,7 +17,7 @@ const TeamsAddTeamForm = () => {
displayName: "",
description: "",
owner: null,
- visibility: "public",
+ visibility: "private",
},
});
From b83689b2ca79eb0c20409d9f0265f1f3204908f2 Mon Sep 17 00:00:00 2001
From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com>
Date: Thu, 11 Jun 2026 18:20:55 +0200
Subject: [PATCH 14/34] fixes required props
---
src/data/standards.json | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/data/standards.json b/src/data/standards.json
index 9139734f7aa1..a698010ce141 100644
--- a/src/data/standards.json
+++ b/src/data/standards.json
@@ -8001,12 +8001,14 @@
"options": [
{ "label": "Allow listed AAGUIDs only", "value": "allow" },
{ "label": "Block listed AAGUIDs", "value": "block" }
- ]
+ ],
+ "required": false
},
{
"type": "textField",
"name": "standards.FIDO2PasskeyProfiles.AAGUIDs",
- "label": "AAGUIDs (comma-separated list of authenticator AAGUIDs)"
+ "label": "AAGUIDs (comma-separated list of authenticator AAGUIDs)",
+ "required": false
}
],
"label": "Configure FIDO2 Passkey Profile",
From 42625b454f4719fa95d4f9acaf38a940932af357 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Fri, 12 Jun 2026 10:31:38 +0800
Subject: [PATCH 15/34] Update CippPolicyImportDrawer.jsx
---
.../CippComponents/CippPolicyImportDrawer.jsx | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx
index bbd1dbe73751..7c1630ce2eb4 100644
--- a/src/components/CippComponents/CippPolicyImportDrawer.jsx
+++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx
@@ -32,10 +32,11 @@ export const CippPolicyImportDrawer = ({
const [viewDialogOpen, setViewDialogOpen] = useState(false)
const [viewingPolicy, setViewingPolicy] = useState(null)
const [selectedFile, setSelectedFile] = useState(null)
- const formControl = useForm()
+ const formControl = useForm({ defaultValues: { forceImport: true } })
const selectedSource = useWatch({ control: formControl.control, name: 'policySource' })
const tenantFilter = useWatch({ control: formControl.control, name: 'tenantFilter' })
+ const forceImport = useWatch({ control: formControl.control, name: 'forceImport' })
// API calls
const communityRepos = ApiGetCall({
@@ -161,6 +162,7 @@ export const CippPolicyImportDrawer = ({
Path: policy.path,
Branch: 'main',
Type: mode,
+ Force: !!forceImport,
},
})
}
@@ -346,6 +348,17 @@ export const CippPolicyImportDrawer = ({
/>
)}
+
+ {selectedSource?.value && selectedSource?.value !== 'tenant' && (
+
+
+
+ )}
{/* Content based on source */}
From 907075a9107b3feaba5a4879a94352bd72630d6c Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Mon, 15 Jun 2026 13:05:51 +0800
Subject: [PATCH 16/34] build optimisations
---
package.json | 2 +-
{src/data => public}/intuneCollection.json | 0
scripts/skip-export-build-traces.mjs | 41 +++++++++
.../CippCards/CippStandardsDialog.jsx | 4 +-
.../CippComponents/CippCodeBlock.jsx | 23 +++--
.../CippComponents/CippFormComponent.jsx | 90 ++++---------------
.../CippComponents/CippPrismHighlighter.jsx | 12 +++
.../CippComponents/CippRichTextField.jsx | 90 +++++++++++++++++++
.../CippTemplateFieldRenderer.jsx | 5 +-
src/components/CippFormPages/CippJSONView.jsx | 14 +--
.../CippStandards/CippStandardAccordion.jsx | 4 +-
.../CippTestDetailOffCanvas.jsx | 4 +-
src/components/pdfExportButton.js | 10 ++-
src/contexts/tutorial-context.js | 6 +-
src/hooks/use-intune-collection.js | 50 +++++++++++
src/hooks/use-securescore.js | 4 +-
src/pages/cipp/settings/licenses.js | 5 +-
src/pages/tenant/manage/applied-standards.js | 20 ++---
src/pages/tenant/manage/drift.js | 8 +-
src/pages/tenant/manage/policies-deployed.js | 4 +-
src/pages/tenant/standards/alignment/index.js | 12 +--
src/utils/get-cipp-formatting.js | 6 +-
src/utils/get-cipp-license-catalog.js | 9 +-
src/utils/get-cipp-license-translation.js | 32 ++++---
src/utils/get-cipp-tenant-group-options.js | 7 +-
src/utils/m365-licenses-data.js | 62 +++++++++++++
src/utils/standards-data.js | 51 +++++++++++
27 files changed, 422 insertions(+), 153 deletions(-)
rename {src/data => public}/intuneCollection.json (100%)
create mode 100644 scripts/skip-export-build-traces.mjs
create mode 100644 src/components/CippComponents/CippPrismHighlighter.jsx
create mode 100644 src/components/CippComponents/CippRichTextField.jsx
create mode 100644 src/hooks/use-intune-collection.js
create mode 100644 src/utils/m365-licenses-data.js
create mode 100644 src/utils/standards-data.js
diff --git a/package.json b/package.json
index e0938a20d56b..94b04e986410 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
},
"scripts": {
"dev": "next -H 127.0.0.1",
- "build": "next build --webpack && rm -rf package.json yarn.lock",
+ "build": "node scripts/skip-export-build-traces.mjs && next build --webpack && rm -rf package.json yarn.lock",
"start": "next start",
"export": "next export",
"lint": "npx eslint .",
diff --git a/src/data/intuneCollection.json b/public/intuneCollection.json
similarity index 100%
rename from src/data/intuneCollection.json
rename to public/intuneCollection.json
diff --git a/scripts/skip-export-build-traces.mjs b/scripts/skip-export-build-traces.mjs
new file mode 100644
index 000000000000..dc20a9dbc6f9
--- /dev/null
+++ b/scripts/skip-export-build-traces.mjs
@@ -0,0 +1,41 @@
+// Workaround for a Next.js 16 build-time waste on `output: 'export'` builds.
+//
+// Next traces server-side file dependencies with @vercel/nft ("Collecting build traces") on every
+// webpack build. For a static export there is NO server runtime, so that trace is never used — yet it
+// costs roughly HALF the build wall-clock (measured ~256s; the single biggest serial phase, and on a
+// 2-core CI runner it stacks on top of static generation). Next 14 let you skip it with
+// `outputFileTracing: false`; Next 15+ removed that option and ships no replacement.
+//
+// This idempotently patches the installed Next build to gate the NFT step on `config.output !== 'export'`
+// — exactly what the old flag did. It's safe: the trace result is never consumed for an export build
+// (only `output: 'standalone'` reads it), and Next already does `await buildTracesPromise` where an
+// unset (undefined) promise is a no-op. Run it immediately before `next build`. Re-running is harmless.
+// Remove once Next skips build traces for `output: 'export'` upstream (track: vercel/next.js).
+import { readFileSync, writeFileSync } from "node:fs";
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+
+let target;
+try {
+ target = require.resolve("next/dist/build/index.js");
+} catch {
+ console.warn("[skip-export-traces] next/dist/build/index.js not resolvable — skipping (build still works)");
+ process.exit(0);
+}
+
+const NEEDLE = "!isGenerateMode && !buildTracesPromise) {";
+const PATCHED = "!isGenerateMode && !buildTracesPromise && config.output !== 'export') {";
+
+let src = readFileSync(target, "utf8");
+if (src.includes(PATCHED)) {
+ console.log("[skip-export-traces] already patched — Next will skip build traces for output: export");
+} else if (src.includes(NEEDLE)) {
+ writeFileSync(target, src.replace(NEEDLE, PATCHED));
+ console.log("[skip-export-traces] patched: Next will skip @vercel/nft build traces for output: export");
+} else {
+ console.warn(
+ "[skip-export-traces] WARNING: patch site not found — Next internals changed for this version. " +
+ "Build proceeds unpatched (just slower). Re-verify the gate in next/dist/build/index.js."
+ );
+}
diff --git a/src/components/CippCards/CippStandardsDialog.jsx b/src/components/CippCards/CippStandardsDialog.jsx
index 0e006ef43615..428184817873 100644
--- a/src/components/CippCards/CippStandardsDialog.jsx
+++ b/src/components/CippCards/CippStandardsDialog.jsx
@@ -36,7 +36,7 @@ import {
ExpandMore as ExpandMoreIcon,
} from '@mui/icons-material'
import { SvgIcon } from '@mui/material'
-import standards from '../../data/standards.json'
+import { getStandards } from '../../utils/standards-data'
const getCategoryIcon = (category) => {
switch (category) {
@@ -136,7 +136,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan
let totalStandardsCount = 0
Object.entries(combinedStandards).forEach(([standardKey, standardConfig]) => {
- const standardInfo = standards.find((s) => s.name === `standards.${standardKey}`)
+ const standardInfo = getStandards().find((s) => s.name === `standards.${standardKey}`)
if (standardInfo) {
const category = standardInfo.cat
if (!standardsByCategory[category]) {
diff --git a/src/components/CippComponents/CippCodeBlock.jsx b/src/components/CippComponents/CippCodeBlock.jsx
index 4331293ccb60..6023fd6914ca 100644
--- a/src/components/CippComponents/CippCodeBlock.jsx
+++ b/src/components/CippComponents/CippCodeBlock.jsx
@@ -1,11 +1,20 @@
import { useState } from "react";
-import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
-import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import dynamic from "next/dynamic";
import { CippCopyToClipBoard } from "./CippCopyToClipboard";
import { styled } from "@mui/system"; // Correct import from @mui/system
-import { Editor } from "@monaco-editor/react";
import { useSettings } from "../../hooks/use-settings";
+// Heavy, client-only editors loaded on demand so monaco-editor (~5MB) and react-syntax-highlighter
+// stay out of the common bundle — they only download when a code block actually renders.
+const Editor = dynamic(() => import("@monaco-editor/react").then((m) => m.Editor), {
+ ssr: false,
+ loading: () => null,
+});
+const CippPrismHighlighter = dynamic(() => import("./CippPrismHighlighter"), {
+ ssr: false,
+ loading: () => null,
+});
+
const CodeContainer = styled("div")`
position: relative;
display: block;
@@ -68,16 +77,14 @@ export const CippCodeBlock = (props) => {
/>
)}
{type === "syntax" && (
-
- {code}
-
+ code={code}
+ />
)}
);
diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx
index 049337ccaf25..628a9aa0dafc 100644
--- a/src/components/CippComponents/CippFormComponent.jsx
+++ b/src/components/CippComponents/CippFormComponent.jsx
@@ -20,20 +20,20 @@ import { Controller, useFormState } from "react-hook-form";
import { DateTimePicker } from "@mui/x-date-pickers"; // Make sure to install @mui/x-date-pickers
import CSVReader from "../CSVReader";
import get from "lodash/get";
-import {
- MenuButtonBold,
- MenuButtonItalic,
- MenuControlsContainer,
- MenuDivider,
- MenuSelectHeading,
- RichTextEditor,
-} from "mui-tiptap";
-import StarterKit from "@tiptap/starter-kit";
+import dynamic from "next/dynamic";
import { CippDataTable } from "../CippTable/CippDataTable";
import React from "react";
import { CloudUpload } from "@mui/icons-material";
import { Stack } from "@mui/system";
+// The tiptap / prosemirror / mui-tiptap editor tree is large and only used by `richText` fields.
+// Load it on demand via next/dynamic so it is code-split into an async chunk instead of being
+// pulled into the shared bundle that every page using CippFormComponent loads. See CippRichTextField.jsx.
+const CippRichTextField = dynamic(() => import("./CippRichTextField"), {
+ ssr: false,
+ loading: () => null,
+});
+
// Helper function to convert bracket notation to dot notation
// Improved to correctly handle nested bracket notations
const convertBracketsToDots = (name) => {
@@ -485,72 +485,14 @@ export const CippFormComponent = (props) => {
}
case "richText": {
- const editorInstanceRef = React.useRef(null);
- const lastSetValue = React.useRef(null);
-
return (
- <>
-
- {
- const { value, onChange, ref } = field;
-
- // Update content when value changes externally
- React.useEffect(() => {
- if (
- editorInstanceRef.current &&
- typeof value === "string" &&
- value !== lastSetValue.current
- ) {
- editorInstanceRef.current.commands.setContent(value || "", false);
- lastSetValue.current = value;
- }
- }, [value]);
-
- return (
- <>
- {label}
- {
- editorInstanceRef.current = editor;
- // Set initial content when editor is created
- if (typeof value === "string") {
- editor.commands.setContent(value || "", false);
- lastSetValue.current = value;
- }
- }}
- onUpdate={({ editor }) => {
- const newValue = editor.getHTML();
- lastSetValue.current = newValue;
- onChange(newValue);
- }}
- label={label}
- renderControls={() => (
-
-
-
-
-
-
- )}
- />
- >
- );
- }}
- />
-
-
- {get(errors, convertedName, {}).message}
-
- >
+
);
}
case "CSVReader":
diff --git a/src/components/CippComponents/CippPrismHighlighter.jsx b/src/components/CippComponents/CippPrismHighlighter.jsx
new file mode 100644
index 000000000000..c19b4a07ed26
--- /dev/null
+++ b/src/components/CippComponents/CippPrismHighlighter.jsx
@@ -0,0 +1,12 @@
+// Isolated leaf so react-syntax-highlighter (+ the prism theme) is code-split out of the common
+// bundle and only downloaded when a syntax block actually renders. Imported via next/dynamic.
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
+
+export default function CippPrismHighlighter({ code, ...props }) {
+ return (
+
+ {code}
+
+ );
+}
diff --git a/src/components/CippComponents/CippRichTextField.jsx b/src/components/CippComponents/CippRichTextField.jsx
new file mode 100644
index 000000000000..5d569ff43015
--- /dev/null
+++ b/src/components/CippComponents/CippRichTextField.jsx
@@ -0,0 +1,90 @@
+// Rich-text editor field, split out of CippFormComponent so the heavy tiptap / prosemirror /
+// mui-tiptap dependency tree (~hundreds of KB) is code-split into an async chunk and only
+// downloaded on the forms that actually render a `richText` field — instead of being pulled into
+// the shared common/_app bundle that every page using CippFormComponent loads. Imported via
+// next/dynamic from CippFormComponent. Self-contained: derives `errors` from the passed formControl.
+import { Typography } from "@mui/material";
+import { Controller, useFormState } from "react-hook-form";
+import {
+ MenuButtonBold,
+ MenuButtonItalic,
+ MenuControlsContainer,
+ MenuDivider,
+ MenuSelectHeading,
+ RichTextEditor,
+} from "mui-tiptap";
+import StarterKit from "@tiptap/starter-kit";
+import React from "react";
+import get from "lodash/get";
+
+export default function CippRichTextField(props) {
+ const { convertedName, formControl, validators, label, ...other } = props;
+ const { errors } = useFormState({ control: formControl.control });
+ const editorInstanceRef = React.useRef(null);
+ const lastSetValue = React.useRef(null);
+
+ return (
+ <>
+
+ {
+ const { value, onChange, ref } = field;
+
+ // Update content when value changes externally
+ React.useEffect(() => {
+ if (
+ editorInstanceRef.current &&
+ typeof value === "string" &&
+ value !== lastSetValue.current
+ ) {
+ editorInstanceRef.current.commands.setContent(value || "", false);
+ lastSetValue.current = value;
+ }
+ }, [value]);
+
+ return (
+ <>
+ {label}
+ {
+ editorInstanceRef.current = editor;
+ // Set initial content when editor is created
+ if (typeof value === "string") {
+ editor.commands.setContent(value || "", false);
+ lastSetValue.current = value;
+ }
+ }}
+ onUpdate={({ editor }) => {
+ const newValue = editor.getHTML();
+ lastSetValue.current = newValue;
+ onChange(newValue);
+ }}
+ label={label}
+ renderControls={() => (
+
+
+
+
+
+
+ )}
+ />
+ >
+ );
+ }}
+ />
+
+
+ {get(errors, convertedName, {}).message}
+
+ >
+ );
+}
diff --git a/src/components/CippComponents/CippTemplateFieldRenderer.jsx b/src/components/CippComponents/CippTemplateFieldRenderer.jsx
index 8ecef26973eb..f9a3cebf7e4e 100644
--- a/src/components/CippComponents/CippTemplateFieldRenderer.jsx
+++ b/src/components/CippComponents/CippTemplateFieldRenderer.jsx
@@ -3,13 +3,14 @@ import { Typography, Divider } from "@mui/material";
import { Grid } from "@mui/system";
import CippFormComponent from "./CippFormComponent";
import { getCippTranslation } from "../../utils/get-cipp-translation";
-import intuneCollection from "../../data/intuneCollection.json";
+import { useIntuneCollection } from "../../hooks/use-intune-collection";
const CippTemplateFieldRenderer = ({
templateData,
formControl,
templateType = "conditionalAccess",
}) => {
+ const intuneCollection = useIntuneCollection();
const intuneDefinitionMap = useMemo(() => {
const map = new Map();
(intuneCollection || []).forEach((def) => {
@@ -18,7 +19,7 @@ const CippTemplateFieldRenderer = ({
}
});
return map;
- }, []);
+ }, [intuneCollection]);
// Default blacklisted fields with wildcard support
const defaultBlacklistedFields = [
"id",
diff --git a/src/components/CippFormPages/CippJSONView.jsx b/src/components/CippFormPages/CippJSONView.jsx
index fb69ef966b37..238399cfce11 100644
--- a/src/components/CippFormPages/CippJSONView.jsx
+++ b/src/components/CippFormPages/CippJSONView.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react'
+import React, { useState, useEffect, useMemo } from 'react'
import {
Accordion,
AccordionSummary,
@@ -22,7 +22,7 @@ import { PropertyList } from '../property-list'
import { getCippTranslation } from '../../utils/get-cipp-translation'
import { getCippFormatting } from '../../utils/get-cipp-formatting'
import { CippCodeBlock } from '../CippComponents/CippCodeBlock'
-import intuneCollection from '../../data/intuneCollection.json'
+import { useIntuneCollection } from '../../hooks/use-intune-collection'
import { useGuidResolver } from '../../hooks/use-guid-resolver'
import { useAdminTemplateDefinitions } from '../../hooks/use-admin-template-definitions'
import {
@@ -31,10 +31,6 @@ import {
extractBindGuid,
} from '../../utils/intune-bind-helpers'
-const intuneCollectionMap = new Map(
- (intuneCollection || []).filter((item) => item?.id).map((item) => [item.id, item])
-)
-
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>)]+)/g
const renderTextWithLinks = (text) => {
@@ -274,6 +270,12 @@ function CippJsonView({
waiting: resolvedType === 'intune',
})
+ const intuneCollection = useIntuneCollection()
+ const intuneCollectionMap = useMemo(
+ () => new Map((intuneCollection || []).filter((item) => item?.id).map((item) => [item.id, item])),
+ [intuneCollection]
+ )
+
const renderIntuneItems = (data) => {
const items = []
const liveDefinitions = new Map()
diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx
index 931a5c2cc271..fc9b8ca96aa3 100644
--- a/src/components/CippStandards/CippStandardAccordion.jsx
+++ b/src/components/CippStandards/CippStandardAccordion.jsx
@@ -41,7 +41,7 @@ import Defender from "../../icons/iconly/bulk/defender";
import Intune from "../../icons/iconly/bulk/intune";
import GDAPRoles from "../../data/GDAPRoles";
import timezoneList from "../../data/timezoneList";
-import standards from "../../data/standards.json";
+import { getStandards } from "../../utils/standards-data";
import { CippFormCondition } from "../CippComponents/CippFormCondition";
import { CippPolicyImportDrawer } from "../CippComponents/CippPolicyImportDrawer";
import ReactMarkdown from "react-markdown";
@@ -420,7 +420,7 @@ const CippStandardAccordion = ({
return;
}
- const standardInfo = standards.find((s) => s.name === baseStandardName);
+ const standardInfo = getStandards().find((s) => s.name === baseStandardName);
const category = standardInfo?.cat || "Other Standards";
if (!result[category]) {
diff --git a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx
index 990ed7472a08..e16c50563554 100644
--- a/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx
+++ b/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx
@@ -4,7 +4,7 @@ import { KeyboardArrowRight } from "@mui/icons-material";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Grid } from "@mui/system";
-import standardsData from "../../data/standards.json";
+import { getStandards } from "../../utils/standards-data";
import { CippCodeBlock } from "../CippComponents/CippCodeBlock";
import { renderCustomScriptMarkdownTemplate } from "../../utils/customScriptTemplate";
@@ -54,7 +54,7 @@ const getImpactColor = (impact) => {
// row's RowKey is the same TestId, so this is an exact lookup.
const getMatchingStandards = (testName) => {
if (!testName) return [];
- return standardsData.filter(
+ return getStandards().filter(
(standard) =>
Array.isArray(standard.appliesToTest) && standard.appliesToTest.includes(testName)
);
diff --git a/src/components/pdfExportButton.js b/src/components/pdfExportButton.js
index f8939bb59ead..93b415d9908f 100644
--- a/src/components/pdfExportButton.js
+++ b/src/components/pdfExportButton.js
@@ -1,7 +1,5 @@
import { IconButton, Tooltip } from '@mui/material'
import { PictureAsPdf } from '@mui/icons-material'
-import jsPDF from 'jspdf'
-import autoTable from 'jspdf-autotable'
import { getCippFormatting } from '../utils/get-cipp-formatting'
import { useSettings } from '../hooks/use-settings'
@@ -22,7 +20,7 @@ const flattenObject = (obj, parentKey = '') => {
}
// Shared helper so the toolbar buttons and bulk export path share the same PDF logic.
-export const exportRowsToPdf = ({
+export const exportRowsToPdf = async ({
rows = [],
columns = [],
reportName = 'Export',
@@ -33,6 +31,12 @@ export const exportRowsToPdf = ({
return
}
+ // Lazy-load jsPDF (+autotable) so ~1MB of PDF code stays out of the common bundle until an export.
+ const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
+ import('jspdf'),
+ import('jspdf-autotable'),
+ ])
+
const unit = 'pt'
const size = 'A3'
const orientation = 'landscape'
diff --git a/src/contexts/tutorial-context.js b/src/contexts/tutorial-context.js
index b32ab50c8fa8..3e62d175c73a 100644
--- a/src/contexts/tutorial-context.js
+++ b/src/contexts/tutorial-context.js
@@ -1,5 +1,4 @@
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'
@@ -119,9 +118,12 @@ export const TutorialProvider = ({ children }) => {
[router]
)
- const runDriver = useCallback((tutorial) => {
+ const runDriver = useCallback(async (tutorial) => {
setActiveTutorial(tutorial)
+ // driver.js is loaded on demand so its ~70 modules stay out of the shared bundle; tours are rare.
+ const { driver } = await import('driver.js')
+
const driverObj = driver({
showProgress: true,
animate: true,
diff --git a/src/hooks/use-intune-collection.js b/src/hooks/use-intune-collection.js
new file mode 100644
index 000000000000..6ae070a3b550
--- /dev/null
+++ b/src/hooks/use-intune-collection.js
@@ -0,0 +1,50 @@
+import { useEffect, useState } from "react";
+
+// The Intune setting catalog (~17MB) lives in public/ and is fetched on demand — only the Intune
+// template / JSON-view screens need it. Keeping it in public/ (a plain static file) instead of a JS
+// import keeps the 17MB out of the webpack/Next compile entirely (faster build, less memory); CRAFT
+// still serves it precompressed (/intuneCollection.json.br). Cached at module scope so it downloads at
+// most once across components.
+let cache = null;
+let pending = null;
+
+function loadIntuneCollection() {
+ if (cache) return Promise.resolve(cache);
+ if (!pending) {
+ pending = fetch("/intuneCollection.json")
+ .then((r) => {
+ if (!r.ok) throw new Error(`intuneCollection.json HTTP ${r.status}`);
+ return r.json();
+ })
+ .then((data) => {
+ cache = data;
+ return cache;
+ })
+ .catch((err) => {
+ pending = null; // allow a retry on next mount
+ throw err;
+ });
+ }
+ return pending;
+}
+
+// Returns the Intune collection array — empty until the fetch resolves, then re-renders.
+export function useIntuneCollection() {
+ const [data, setData] = useState(cache || []);
+ useEffect(() => {
+ if (cache) {
+ setData(cache);
+ return;
+ }
+ let alive = true;
+ loadIntuneCollection()
+ .then((d) => {
+ if (alive) setData(d);
+ })
+ .catch(() => {});
+ return () => {
+ alive = false;
+ };
+ }, []);
+ return data;
+}
diff --git a/src/hooks/use-securescore.js b/src/hooks/use-securescore.js
index c3fa021b2557..8101dd316586 100644
--- a/src/hooks/use-securescore.js
+++ b/src/hooks/use-securescore.js
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { ApiGetCall } from "../api/ApiCall";
import { useSettings } from "./use-settings";
-import standards from "../data/standards.json";
+import { getStandards } from "../utils/standards-data";
export function useSecureScore({ waiting = true } = {}) {
const currentTenant = useSettings().currentTenant;
@@ -57,7 +57,7 @@ export function useSecureScore({ waiting = true } = {}) {
const translation = controlScore.data.Results?.find(
(controlTranslation) => controlTranslation.id === control.controlName,
);
- const remediation = standards.find((standard) =>
+ const remediation = getStandards().find((standard) =>
standard.tag?.includes(control.controlName),
);
return {
diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js
index 5e233744c0eb..f8795293be53 100644
--- a/src/pages/cipp/settings/licenses.js
+++ b/src/pages/cipp/settings/licenses.js
@@ -9,8 +9,7 @@ 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 { getM365Licenses } from '../../../utils/m365-licenses-data'
import { useMemo, useCallback } from 'react'
const Page = () => {
@@ -21,7 +20,7 @@ const Page = () => {
const simpleColumns = ['Product_Display_Name', 'GUID', 'ExclusionType', 'ShowInLicenseDropdown']
const allLicenseOptions = useMemo(() => {
- const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional]
+ const allLicenses = getM365Licenses()
const uniqueLicenses = new Map()
allLicenses.forEach((license) => {
diff --git a/src/pages/tenant/manage/applied-standards.js b/src/pages/tenant/manage/applied-standards.js
index acd6a06a5061..7269f943d25c 100644
--- a/src/pages/tenant/manage/applied-standards.js
+++ b/src/pages/tenant/manage/applied-standards.js
@@ -38,7 +38,7 @@ import {
Check,
Warning,
} from '@mui/icons-material'
-import standards from '../../../data/standards.json'
+import { getStandards } from '../../../utils/standards-data'
import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog'
import { SvgIcon } from '@mui/material'
import { useForm } from 'react-hook-form'
@@ -167,7 +167,7 @@ const Page = () => {
tagTemplates.forEach((expandedTemplate) => {
const itemTemplateId = expandedTemplate.GUID
const standardId = `standards.IntuneTemplate.${itemTemplateId}`
- const standardInfo = standards.find(
+ const standardInfo = getStandards().find(
(s) => s.name === `standards.IntuneTemplate`
)
@@ -293,7 +293,7 @@ const Page = () => {
const itemTemplateId = templateItem.TemplateList?.value
if (itemTemplateId) {
const standardId = `standards.IntuneTemplate.${itemTemplateId}`
- const standardInfo = standards.find(
+ const standardInfo = getStandards().find(
(s) => s.name === `standards.IntuneTemplate`
)
@@ -434,7 +434,7 @@ const Page = () => {
tagTemplates.forEach((expandedTemplate) => {
const itemTemplateId = expandedTemplate.GUID
const standardId = `standards.ConditionalAccessTemplate.${itemTemplateId}`
- const standardInfo = standards.find(
+ const standardInfo = getStandards().find(
(s) => s.name === `standards.ConditionalAccessTemplate`
)
@@ -551,7 +551,7 @@ const Page = () => {
const itemTemplateId = templateItem.TemplateList?.value
if (itemTemplateId) {
const standardId = `standards.ConditionalAccessTemplate.${itemTemplateId}`
- const standardInfo = standards.find(
+ const standardInfo = getStandards().find(
(s) => s.name === `standards.ConditionalAccessTemplate`
)
@@ -674,7 +674,7 @@ const Page = () => {
if (!displayName) return
const standardId = `standards.QuarantineTemplate.${displayName}`
- const standardInfo = standards.find(
+ const standardInfo = getStandards().find(
(s) => s.name === 'standards.QuarantineTemplate'
)
@@ -770,7 +770,7 @@ const Page = () => {
const groupTemplates = standardConfig.groupTemplate || []
const actions = standardConfig.action || []
const standardId = `standards.GroupTemplate`
- const standardInfo = standards.find((s) => s.name === standardId)
+ const standardInfo = getStandards().find((s) => s.name === standardId)
// Find the tenant's value for this template
const currentTenantStandard = currentTenantData.find(
@@ -909,7 +909,7 @@ const Page = () => {
} else {
// Regular handling for other standards
const standardId = `standards.${standardKey}`
- const standardInfo = standards.find((s) => s.name === standardId)
+ const standardInfo = getStandards().find((s) => s.name === standardId)
const standardSettings = standardConfig.standards?.[standardKey] || {}
//console.log(standardInfo);
@@ -1121,7 +1121,7 @@ const Page = () => {
if (standardObject?.TemplateId !== templateId) return
const itemTemplateId = key.replace('standards.IntuneTemplate.', '')
- const standardInfo = standards.find((s) => s.name === 'standards.IntuneTemplate')
+ const standardInfo = getStandards().find((s) => s.name === 'standards.IntuneTemplate')
const directStandardValue = standardObject?.Value
let isCompliant = false
@@ -1263,7 +1263,7 @@ const Page = () => {
comparisonData.forEach((standard) => {
// Find the standard info in the standards.json data
- const standardInfo = standards.find((s) => standard.standardId.includes(s.name))
+ const standardInfo = getStandards().find((s) => standard.standardId.includes(s.name))
// Use the category from standards.json, or default to "Other Standards"
const category = standardInfo?.cat || 'Other Standards'
diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js
index df2a3869dc38..97bcd6346c36 100644
--- a/src/pages/tenant/manage/drift.js
+++ b/src/pages/tenant/manage/drift.js
@@ -39,7 +39,7 @@ import { useSettings } from '../../../hooks/use-settings'
import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog'
import { useDialog } from '../../../hooks/use-dialog'
import tabOptions from './tabOptions.json'
-import standardsData from '../../../data/standards.json'
+import { getStandards } from '../../../utils/standards-data'
import { createDriftManagementActions } from './driftManagementActions'
import { ExecutiveReportButton } from '../../../components/ExecutiveReportButton'
import { CippAutoComplete } from '../../../components/CippComponents/CippAutocomplete'
@@ -384,7 +384,7 @@ const ManageDriftPage = () => {
if (!standardName) return 'Unknown Standard'
// Find the standard in standards.json by name
- const standard = standardsData.find((s) => s.name === standardName)
+ const standard = getStandards().find((s) => s.name === standardName)
if (standard && standard.label) {
return standard.label
}
@@ -399,7 +399,7 @@ const ManageDriftPage = () => {
if (!standardName) return null
// Find the standard in standards.json by name
- const standard = standardsData.find((s) => s.name === standardName)
+ const standard = getStandards().find((s) => s.name === standardName)
if (standard) {
return standard.helpText || standard.docsDescription || standard.executiveText || null
}
@@ -1550,7 +1550,7 @@ const ManageDriftPage = () => {
if (standardName.includes('QuarantineTemplate')) return 'Defender Standards'
// For other standards, look up category in standards.json
- const standard = standardsData.find((s) => s.name === standardName)
+ const standard = getStandards().find((s) => s.name === standardName)
if (standard && standard.cat) {
return standard.cat
}
diff --git a/src/pages/tenant/manage/policies-deployed.js b/src/pages/tenant/manage/policies-deployed.js
index 3b24143cee55..8b084433795d 100644
--- a/src/pages/tenant/manage/policies-deployed.js
+++ b/src/pages/tenant/manage/policies-deployed.js
@@ -15,7 +15,7 @@ import tabOptions from './tabOptions.json'
import { CippDataTable } from '../../../components/CippTable/CippDataTable'
import { CippHead } from '../../../components/CippComponents/CippHead'
import { ApiGetCall } from '../../../api/ApiCall'
-import standardsData from '../../../data/standards.json'
+import { getStandards } from '../../../utils/standards-data'
import { createDriftManagementActions } from './driftManagementActions'
import { useSettings } from '../../../hooks/use-settings'
import { CippAutoComplete } from '../../../components/CippComponents/CippAutocomplete'
@@ -200,7 +200,7 @@ const PoliciesDeployedPage = () => {
// Helper function to get standard name from standards.json
const getStandardName = (standardKey) => {
const standardName = `standards.${standardKey}`
- const standard = standardsData.find((s) => s.name === standardName)
+ const standard = getStandards().find((s) => s.name === standardName)
return standard?.label || standardKey.replace(/([A-Z])/g, ' $1').trim()
}
diff --git a/src/pages/tenant/standards/alignment/index.js b/src/pages/tenant/standards/alignment/index.js
index 60c05489bc24..4137a2c9a280 100644
--- a/src/pages/tenant/standards/alignment/index.js
+++ b/src/pages/tenant/standards/alignment/index.js
@@ -19,7 +19,7 @@ import {
Tooltip,
Typography,
} from '@mui/material'
-import standardsData from '../../../../data/standards.json'
+import { getStandards } from '../../../../utils/standards-data'
const complianceColors = {
compliant: 'success',
@@ -64,8 +64,8 @@ const getPageRows = (page) => {
const getStandardInfo = (standardId) => {
const baseName = standardId?.split('.').slice(0, -1).join('.')
return (
- standardsData.find((s) => s.name === standardId) ??
- standardsData.find((s) => s.name === baseName)
+ getStandards().find((s) => s.name === standardId) ??
+ getStandards().find((s) => s.name === baseName)
)
}
@@ -117,7 +117,7 @@ const Page = () => {
if (!standardKey) return
const standardInfo = getStandardInfo(row.standardId)
- const hasExactMatch = standardsData.find((s) => s.name === row.standardId)
+ const hasExactMatch = getStandards().find((s) => s.name === row.standardId)
const standardName = hasExactMatch
? (standardInfo?.label ?? row.standardName ?? standardKey)
: (row.standardName ?? standardInfo?.label ?? standardKey)
@@ -429,8 +429,8 @@ const Page = () => {
const diffs = compareValues(expectedParsed, currentParsed)
const baseName = row.standardId?.split('.').slice(0, -1).join('.')
const prettyName =
- standardsData.find((s) => s.name === row.standardId)?.label ??
- standardsData.find((s) => s.name === baseName)?.label ??
+ getStandards().find((s) => s.name === row.standardId)?.label ??
+ getStandards().find((s) => s.name === baseName)?.label ??
row.standardName
const statusColor = getComplianceColor(row.complianceStatus)
diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js
index fc313c53cae7..3c6b6a066031 100644
--- a/src/utils/get-cipp-formatting.js
+++ b/src/utils/get-cipp-formatting.js
@@ -34,7 +34,7 @@ import DOMPurify from 'dompurify'
import { getSignInErrorCodeTranslation } from './get-cipp-signin-errorcode-translation'
import { CollapsibleChipList } from '../components/CippComponents/CollapsibleChipList'
import countryList from '../data/countryList.json'
-import standardsData from '../data/standards.json'
+import { getStandards } from './standards-data'
// Helper function to convert country codes to country names
const getCountryNameFromCode = (countryCode) => {
@@ -472,8 +472,8 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr
if (!data?.startsWith('standards.')) return isText ? data : {data}
const baseName = data.split('.').slice(0, -1).join('.')
const label =
- standardsData.find((s) => s.name === data)?.label ??
- standardsData.find((s) => s.name === baseName)?.label ??
+ getStandards().find((s) => s.name === data)?.label ??
+ getStandards().find((s) => s.name === baseName)?.label ??
data
return label
}
diff --git a/src/utils/get-cipp-license-catalog.js b/src/utils/get-cipp-license-catalog.js
index 75a02aa1bffa..ae1a060d545e 100644
--- a/src/utils/get-cipp-license-catalog.js
+++ b/src/utils/get-cipp-license-catalog.js
@@ -1,5 +1,4 @@
-import M365LicensesDefault from "../data/M365Licenses.json";
-import M365LicensesAdditional from "../data/M365Licenses-additional.json";
+import { getM365Licenses } from "./m365-licenses-data";
// Build an index keyed by lowercased GUID with one entry per SKU (collapsing the
// per-service-plan rows in the source JSON into a single record).
@@ -7,7 +6,7 @@ let catalogCache = null;
const buildCatalog = () => {
const map = new Map();
- for (const row of [...M365LicensesDefault, ...M365LicensesAdditional]) {
+ for (const row of getM365Licenses()) {
if (!row?.GUID) continue;
const key = row.GUID.toLowerCase();
let entry = map.get(key);
@@ -41,8 +40,8 @@ const buildCatalog = () => {
};
const getCatalog = () => {
- if (!catalogCache) catalogCache = buildCatalog();
- return catalogCache;
+ if (!catalogCache && getM365Licenses().length) catalogCache = buildCatalog();
+ return catalogCache || [];
};
/**
diff --git a/src/utils/get-cipp-license-translation.js b/src/utils/get-cipp-license-translation.js
index 321379fc6785..f1e93f7116e1 100644
--- a/src/utils/get-cipp-license-translation.js
+++ b/src/utils/get-cipp-license-translation.js
@@ -1,18 +1,26 @@
-import M365LicensesDefault from "../data/M365Licenses.json";
-import M365LicensesAdditional from "../data/M365Licenses-additional.json";
+import { getM365Licenses } from "./m365-licenses-data";
import { getCachedLicense } from "./cipp-license-cache";
import licenseBackfillManager from "./cipp-license-backfill-manager";
-// Create a Map for O(1) lookups of GUID to Product_Display_Name
-const licenseByGuid = new Map();
-[...M365LicensesDefault, ...M365LicensesAdditional].forEach((entry) => {
- if (entry.GUID) {
- const key = entry.GUID.toLowerCase();
- if (!licenseByGuid.has(key)) {
- licenseByGuid.set(key, entry.Product_Display_Name);
+// Lazily build a Map for O(1) GUID -> Product_Display_Name lookups once the license data has loaded.
+let _licenseByGuid = null;
+const licenseByGuidMap = () => {
+ if (!_licenseByGuid) {
+ const all = getM365Licenses();
+ if (all.length) {
+ _licenseByGuid = new Map();
+ all.forEach((entry) => {
+ if (entry.GUID) {
+ const key = entry.GUID.toLowerCase();
+ if (!_licenseByGuid.has(key)) {
+ _licenseByGuid.set(key, entry.Product_Display_Name);
+ }
+ }
+ });
}
}
-});
+ return _licenseByGuid || new Map();
+};
export const getCippLicenseTranslation = (licenseArray) => {
let licenses = [];
@@ -35,11 +43,11 @@ export const getCippLicenseTranslation = (licenseArray) => {
// First, check static JSON map (O(1) lookup)
const skuLower = licenseAssignment.skuId?.toLowerCase();
- const displayName = skuLower ? licenseByGuid.get(skuLower) : undefined;
+ const displayName = skuLower ? licenseByGuidMap().get(skuLower) : undefined;
if (displayName) {
licenses.push(displayName);
found = true;
- } else if (skuLower && licenseByGuid.has(skuLower)) {
+ } else if (skuLower && licenseByGuidMap().has(skuLower)) {
// Entry exists but Product_Display_Name is falsy — fall back to skuPartNumber
licenses.push(licenseAssignment.skuPartNumber || licenseAssignment.skuId);
found = true;
diff --git a/src/utils/get-cipp-tenant-group-options.js b/src/utils/get-cipp-tenant-group-options.js
index 08774854e2aa..97a7fe9a8981 100644
--- a/src/utils/get-cipp-tenant-group-options.js
+++ b/src/utils/get-cipp-tenant-group-options.js
@@ -1,5 +1,4 @@
-import M365LicensesDefault from "../data/M365Licenses.json";
-import M365LicensesAdditional from "../data/M365Licenses-additional.json";
+import { getM365Licenses } from "./m365-licenses-data";
/**
* Get all available licenses for tenant group dynamic rules
@@ -7,7 +6,7 @@ import M365LicensesAdditional from "../data/M365Licenses-additional.json";
*/
export const getTenantGroupLicenseOptions = () => {
// Combine both license files
- const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional];
+ const allLicenses = getM365Licenses();
// Create unique licenses map using String_Id as key for better deduplication
const uniqueLicensesMap = new Map();
@@ -48,7 +47,7 @@ export const getTenantGroupLicenseOptions = () => {
*/
export const getTenantGroupServicePlanOptions = () => {
// Combine both license files
- const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional];
+ const allLicenses = getM365Licenses();
// Create unique service plans map using Service_Plan_Name as key for better deduplication
const uniqueServicePlansMap = new Map();
diff --git a/src/utils/m365-licenses-data.js b/src/utils/m365-licenses-data.js
new file mode 100644
index 000000000000..813f57dc5ced
--- /dev/null
+++ b/src/utils/m365-licenses-data.js
@@ -0,0 +1,62 @@
+import { useEffect, useState } from "react";
+
+// data/M365Licenses.json (~2.2MB) was statically imported by several license utils + a settings page,
+// inlining it into the common bundle. Load it (and the small additional list) as an async chunk
+// instead. Cached at module scope; preloaded on first import so sync consumers usually see data.
+let cache = null;
+let pending = null;
+
+export function ensureM365Licenses() {
+ if (cache) return Promise.resolve(cache);
+ if (!pending) {
+ pending = Promise.all([
+ import("../data/M365Licenses.json"),
+ import("../data/M365Licenses-additional.json"),
+ ])
+ .then(([def, add]) => {
+ const d = def.default || def;
+ const a = add.default || add;
+ cache = { default: d, additional: a, all: [...d, ...a] };
+ return cache;
+ })
+ .catch((err) => {
+ pending = null;
+ throw err;
+ });
+ }
+ return pending;
+}
+
+// Synchronous accessors for non-React utilities — return [] until the chunk has loaded.
+export function getM365Licenses() {
+ return cache?.all || [];
+}
+export function getM365LicensesDefault() {
+ return cache?.default || [];
+}
+export function getM365LicensesAdditional() {
+ return cache?.additional || [];
+}
+
+// Hook for React components — re-renders once the chunk has loaded.
+export function useM365Licenses() {
+ const [data, setData] = useState(cache?.all || []);
+ useEffect(() => {
+ if (cache) {
+ setData(cache.all);
+ return;
+ }
+ let alive = true;
+ ensureM365Licenses()
+ .then((c) => {
+ if (alive) setData(c.all);
+ })
+ .catch(() => {});
+ return () => {
+ alive = false;
+ };
+ }, []);
+ return data;
+}
+
+ensureM365Licenses().catch(() => {});
diff --git a/src/utils/standards-data.js b/src/utils/standards-data.js
new file mode 100644
index 000000000000..5a380e77f7b5
--- /dev/null
+++ b/src/utils/standards-data.js
@@ -0,0 +1,51 @@
+import { useEffect, useState } from "react";
+
+// data/standards.json (~0.4MB) was statically imported by a hot formatting util + several standards
+// screens, inlining it into the common bundle. Load it as its own async chunk instead. Cached at
+// module scope; preloaded on first import so sync consumers usually see data by the time they run.
+let cache = null;
+let pending = null;
+
+export function ensureStandards() {
+ if (cache) return Promise.resolve(cache);
+ if (!pending) {
+ pending = import("../data/standards.json")
+ .then((m) => {
+ cache = m.default || m;
+ return cache;
+ })
+ .catch((err) => {
+ pending = null;
+ throw err;
+ });
+ }
+ return pending;
+}
+
+// Synchronous accessor for non-React utilities — returns [] until the chunk has loaded.
+export function getStandards() {
+ return cache || [];
+}
+
+// Hook for React components — re-renders once the chunk has loaded.
+export function useStandards() {
+ const [data, setData] = useState(cache || []);
+ useEffect(() => {
+ if (cache) {
+ setData(cache);
+ return;
+ }
+ let alive = true;
+ ensureStandards()
+ .then((s) => {
+ if (alive) setData(s);
+ })
+ .catch(() => {});
+ return () => {
+ alive = false;
+ };
+ }, []);
+ return data;
+}
+
+ensureStandards().catch(() => {});
From e89d697a38dfba8da0473e11485ed21ba6c2fb98 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Mon, 15 Jun 2026 19:38:28 +0800
Subject: [PATCH 17/34] contact edit fixes
---
src/components/CippFormPages/CippFormPage.jsx | 11 ++++++++---
.../email/administration/contacts/edit.jsx | 17 +++++++++++++----
2 files changed, 21 insertions(+), 7 deletions(-)
diff --git a/src/components/CippFormPages/CippFormPage.jsx b/src/components/CippFormPages/CippFormPage.jsx
index f29d78388171..6876890a70b6 100644
--- a/src/components/CippFormPages/CippFormPage.jsx
+++ b/src/components/CippFormPages/CippFormPage.jsx
@@ -29,6 +29,7 @@ const CippFormPage = (props) => {
postUrl,
customDataformatter,
resetForm = false,
+ preserveNullValues = false,
hideBackButton = false,
hidePageType = false,
hideTitle = false,
@@ -85,16 +86,20 @@ const CippFormPage = (props) => {
? customDataformatter(formControl.getValues())
: formControl.getValues()
//remove all empty values or blanks (recursively)
+ //when preserveNullValues is set, explicit nulls are kept so the API can
+ //distinguish "clear this field" from "field omitted"
+ const isEmptyValue = (value) =>
+ value === '' || value === undefined || (!preserveNullValues && value === null)
const removeEmpty = (obj) => {
if (Array.isArray(obj)) {
return obj
.map((item) => (item && typeof item === 'object' ? removeEmpty(item) : item))
- .filter((item) => item !== '' && item !== null && item !== undefined)
+ .filter((item) => !isEmptyValue(item))
}
Object.keys(obj).forEach((key) => {
- if (obj[key] === '' || obj[key] === null || obj[key] === undefined) {
+ if (isEmptyValue(obj[key])) {
delete obj[key]
- } else if (typeof obj[key] === 'object') {
+ } else if (obj[key] !== null && typeof obj[key] === 'object') {
obj[key] = removeEmpty(obj[key])
if (!Array.isArray(obj[key]) && Object.keys(obj[key]).length === 0) {
delete obj[key]
diff --git a/src/pages/email/administration/contacts/edit.jsx b/src/pages/email/administration/contacts/edit.jsx
index f47ef7c88d34..aa6bd53ab7ef 100644
--- a/src/pages/email/administration/contacts/edit.jsx
+++ b/src/pages/email/administration/contacts/edit.jsx
@@ -57,9 +57,17 @@ const EditContact = () => {
return null;
}
- const contact = contactInfo.data;
+ const contact = Array.isArray(contactInfo.data) ? contactInfo.data[0] : contactInfo.data;
+ if (!contact) {
+ return null;
+ }
const address = contact.addresses?.[0] || {};
- const phones = contact.phones || [];
+ // A single phone may be serialized as a bare object rather than an array
+ const phones = Array.isArray(contact.phones)
+ ? contact.phones
+ : contact.phones
+ ? [contact.phones]
+ : [];
// Use Map for O(1) phone lookup
const phoneMap = new Map(phones.map((p) => [p.type, p.number]));
@@ -114,8 +122,8 @@ const EditContact = () => {
State: values.state,
CountryOrRegion: values.country?.value || values.country,
Company: values.companyName,
- mobilePhone: values.mobilePhone,
- phone: values.businessPhone,
+ mobilePhone: values.mobilePhone || null,
+ phone: values.businessPhone || null,
website: values.website,
mailTip: values.mailTip,
};
@@ -135,6 +143,7 @@ const EditContact = () => {
postUrl="/api/EditContact"
data={contact}
customDataformatter={customDataFormatter}
+ preserveNullValues
>
{contactInfo.isLoading && }
{!contactInfo.isLoading && (
From 1e5f450b9bf08ebf17d6a040f35fb4962c9c2091 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Tue, 16 Jun 2026 18:00:06 +0800
Subject: [PATCH 18/34] bookmark cleanup
---
src/hooks/use-user-bookmarks.js | 86 +------------------------------
src/layouts/side-nav-bookmarks.js | 22 ++++----
src/layouts/top-nav.js | 9 +---
3 files changed, 16 insertions(+), 101 deletions(-)
diff --git a/src/hooks/use-user-bookmarks.js b/src/hooks/use-user-bookmarks.js
index 7427ea5c06f0..bbb977522942 100644
--- a/src/hooks/use-user-bookmarks.js
+++ b/src/hooks/use-user-bookmarks.js
@@ -1,9 +1,7 @@
-import { useCallback, useEffect, useMemo, useRef } from "react";
+import { useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { ApiGetCall, ApiPostCall } from "../api/ApiCall";
-const SETTINGS_STORAGE_KEY = "app.settings";
-
const sanitizeBookmark = (bookmark) => {
if (!bookmark || typeof bookmark !== "object") {
return null;
@@ -43,47 +41,6 @@ const normalizeBookmarks = (value) => {
return [];
};
-const getLocalStoredBookmarks = () => {
- if (typeof window === "undefined") {
- return [];
- }
-
- try {
- const restored = window.localStorage.getItem(SETTINGS_STORAGE_KEY);
- if (!restored) {
- return [];
- }
-
- const parsed = JSON.parse(restored);
- return normalizeBookmarks(parsed?.bookmarks);
- } catch {
- return [];
- }
-};
-
-const clearLocalStoredBookmarks = () => {
- if (typeof window === "undefined") {
- return;
- }
-
- try {
- const restored = window.localStorage.getItem(SETTINGS_STORAGE_KEY);
- if (!restored) {
- return;
- }
-
- const parsed = JSON.parse(restored);
- if (!parsed || typeof parsed !== "object" || !Object.prototype.hasOwnProperty.call(parsed, "bookmarks")) {
- return;
- }
-
- delete parsed.bookmarks;
- window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(parsed));
- } catch {
- return;
- }
-};
-
const getBookmarksFromSettings = (settingsData) => {
if (!settingsData) {
return [];
@@ -102,8 +59,6 @@ const getBookmarksFromSettings = (settingsData) => {
export const useUserBookmarks = () => {
const queryClient = useQueryClient();
- const localMigrationComplete = useRef(false);
- const localMigrationInFlight = useRef(false);
const userSettings = ApiGetCall({
url: "/api/ListUserSettings",
@@ -163,47 +118,10 @@ export const useUserBookmarks = () => {
[persistBookmarks]
);
- useEffect(() => {
- if (localMigrationComplete.current || localMigrationInFlight.current) {
- return;
- }
-
- if (!auth.data?.clientPrincipal?.userDetails) {
- return;
- }
-
- if (bookmarks.length > 0) {
- localMigrationComplete.current = true;
- return;
- }
-
- const localBookmarks = getLocalStoredBookmarks();
- if (localBookmarks.length === 0) {
- localMigrationComplete.current = true;
- return;
- }
-
- localMigrationInFlight.current = true;
- const didPost = persistBookmarks(localBookmarks, {
- onSuccess: () => {
- clearLocalStoredBookmarks();
- localMigrationInFlight.current = false;
- localMigrationComplete.current = true;
- },
- onError: () => {
- localMigrationInFlight.current = false;
- },
- });
-
- if (!didPost) {
- localMigrationInFlight.current = false;
- }
- }, [auth.data?.clientPrincipal?.userDetails, bookmarks.length, persistBookmarks]);
-
return {
bookmarks,
setBookmarks,
isLoading: userSettings.isLoading,
isSaving: saveBookmarksPost.isPending,
};
-};
\ No newline at end of file
+};
diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js
index 04f4a978609a..0ae0ec7abdec 100644
--- a/src/layouts/side-nav-bookmarks.js
+++ b/src/layouts/side-nav-bookmarks.js
@@ -522,16 +522,18 @@ export const SideNavBookmarks = ({ collapse = false }) => {
>
)}
- {
- e.preventDefault();
- removeBookmark(bookmark.path);
- }}
- sx={{ p: "2px" }}
- >
-
-
+ {!locked && (
+ {
+ e.preventDefault();
+ removeBookmark(bookmark.path);
+ }}
+ sx={{ p: "2px" }}
+ >
+
+
+ )}
diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js
index 10bdcefd9581..fb5c7483e20f 100644
--- a/src/layouts/top-nav.js
+++ b/src/layouts/top-nav.js
@@ -54,7 +54,7 @@ export const TopNav = (props) => {
const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md'))
const showPopoverBookmarks = settings.bookmarkPopover === true
const reorderMode = settings.bookmarkReorderMode || 'arrows'
- const locked = settings.bookmarkLocked ?? false
+ const locked = settings.bookmarkLocked ?? true
const handleThemeSwitch = useCallback(() => {
const themeName = settings.currentTheme?.value === 'light' ? 'dark' : 'light'
settings.handleUpdate({
@@ -590,18 +590,13 @@ export const TopNav = (props) => {
>
)}
- {!(reorderMode === 'drag' && locked) && (
+ {!locked && (
{
e.preventDefault()
- if (locked) {
- triggerLockFlash()
- return
- }
removeBookmark(bookmark.path)
}}
- sx={{ ...(locked && { opacity: 0.4 }) }}
>
From 3c82750d10f50983c940e9b4069eb5b4b002320f Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Tue, 16 Jun 2026 18:21:04 +0800
Subject: [PATCH 19/34] Warning when converting mailbox that is over 49GB
---
.../CippWizard/CippWizardOffboarding.jsx | 55 ++++++++++++++++++-
1 file changed, 54 insertions(+), 1 deletion(-)
diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx
index 990cb9d35b11..2fc5947c22bf 100644
--- a/src/components/CippWizard/CippWizardOffboarding.jsx
+++ b/src/components/CippWizard/CippWizardOffboarding.jsx
@@ -12,9 +12,13 @@ import CippWizardStepButtons from './CippWizardStepButtons'
import CippFormComponent from '../CippComponents/CippFormComponent'
import { CippFormCondition } from '../CippComponents/CippFormCondition'
import { useWatch } from 'react-hook-form'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { Grid } from '@mui/system'
import { useSettings } from '../../hooks/use-settings'
+import { ApiGetCall } from '../../api/ApiCall'
+
+// Shared mailboxes are capped at 50 GiB without a license; warn at 49 GiB.
+const SHARED_MAILBOX_WARN_BYTES = 49 * 1024 ** 3
export const CippWizardOffboarding = (props) => {
const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props
@@ -24,6 +28,40 @@ export const CippWizardOffboarding = (props) => {
const userSettingsDefaults = useSettings().userSettingsDefaults
const disableForwarding = useWatch({ control: formControl.control, name: 'disableForwarding' })
const deleteUser = useWatch({ control: formControl.control, name: 'DeleteUser' })
+ const convertToShared = useWatch({ control: formControl.control, name: 'ConvertToShared' })
+
+ // Pull cached mailbox sizes (storageUsedInBytes, keyed by UPN) only when relevant
+ const mailboxUsage = ApiGetCall({
+ url: '/api/ListMailboxes',
+ data: { tenantFilter: currentTenant?.value, UseReportDB: true },
+ queryKey: `OffboardingMailboxUsage-${currentTenant?.value}`,
+ waiting: !!convertToShared && !!currentTenant?.value && selectedUsers?.length > 0,
+ })
+
+ // Selected mailboxes whose cached size would exceed the shared-mailbox limit
+ const oversizedMailboxes = useMemo(() => {
+ if (!convertToShared || !mailboxUsage.isSuccess || !Array.isArray(mailboxUsage.data)) {
+ return []
+ }
+ const selectedUpns = (selectedUsers || []).map((u) =>
+ (u?.value ?? u)?.toString().toLowerCase(),
+ )
+ return mailboxUsage.data
+ .filter((mb) => {
+ const upn = mb?.UPN?.toString().toLowerCase()
+ const bytes = Number(mb?.storageUsedInBytes)
+ return (
+ upn &&
+ selectedUpns.includes(upn) &&
+ Number.isFinite(bytes) &&
+ bytes >= SHARED_MAILBOX_WARN_BYTES
+ )
+ })
+ .map((mb) => ({
+ upn: mb.UPN,
+ sizeGB: (Number(mb.storageUsedInBytes) / 1024 ** 3).toFixed(1),
+ }))
+ }, [convertToShared, mailboxUsage.isSuccess, mailboxUsage.data, selectedUsers])
useEffect(() => {
if (selectedUsers.length >= 3) {
@@ -383,6 +421,21 @@ export const CippWizardOffboarding = (props) => {
formControl={formControl}
/>
+ {convertToShared && oversizedMailboxes.length > 0 && (
+
+ The following mailbox{oversizedMailboxes.length > 1 ? 'es' : ''} exceed or are near
+ the 50 GB shared mailbox limit. Converting to shared may fail, or the mailbox may
+ stop receiving mail once unlicensed, unless an Exchange Online Plan 2 license is
+ retained:
+
+ {oversizedMailboxes.map((mb) => (
+
+ {mb.upn} ({mb.sizeGB} GB)
+
+ ))}
+
+
+ )}
From ca78e05c8d02c71e4916af1d9a4a3f241d0d3fd0 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Tue, 16 Jun 2026 20:19:42 +0800
Subject: [PATCH 20/34] multi post action for multiple spo site cleanup
---
src/pages/teams-share/sharepoint/index.js | 23 ++++++++++++++---------
1 file changed, 14 insertions(+), 9 deletions(-)
diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js
index 42f08fdc0486..b39b143c6eee 100644
--- a/src/pages/teams-share/sharepoint/index.js
+++ b/src/pages/teams-share/sharepoint/index.js
@@ -264,15 +264,20 @@ const Page = () => {
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,
- }),
+ customDataformatter: (row, action, formData) => {
+ const formatRow = (singleRow) => ({
+ tenantFilter: singleRow.Tenant ?? tenantFilter,
+ SiteUrl: singleRow.webUrl,
+ BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10),
+ DeleteOlderThanDays:
+ formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1,
+ MajorVersionLimit:
+ formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1,
+ })
+ // When multiple rows are selected, row is an array. Returning an array
+ // makes CippApiDialog send one request per row (bulk request mode).
+ return Array.isArray(row) ? row.map(formatRow) : formatRow(row)
+ },
multiPost: false,
},
]
From ff8197060250dbffdd3a65b0392329695b00d184 Mon Sep 17 00:00:00 2001
From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com>
Date: Wed, 17 Jun 2026 14:18:06 +0200
Subject: [PATCH 21/34] required = true
---
src/pages/tenant/manage/drift.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js
index 97bcd6346c36..eabac2dfb1c6 100644
--- a/src/pages/tenant/manage/drift.js
+++ b/src/pages/tenant/manage/drift.js
@@ -2068,6 +2068,7 @@ const ManageDriftPage = () => {
type: 'textField',
name: 'reason',
label: 'Reason for change (Mandatory)',
+ required: true,
},
...(actionData.data?.deviations?.some((d) => d.status === 'DeniedRemediate')
? [
From 383df0af78fb667bc28b93ef943f7c49bc6c8770 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Wed, 17 Jun 2026 22:17:16 +0800
Subject: [PATCH 22/34] repair gdap role mapping action
---
.../CippSettings/CippGDAPResults.jsx | 76 +++++++++++++++++--
1 file changed, 71 insertions(+), 5 deletions(-)
diff --git a/src/components/CippSettings/CippGDAPResults.jsx b/src/components/CippSettings/CippGDAPResults.jsx
index 46d505a4535d..306c7451eb32 100644
--- a/src/components/CippSettings/CippGDAPResults.jsx
+++ b/src/components/CippSettings/CippGDAPResults.jsx
@@ -1,15 +1,34 @@
-import { Alert, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material";
+import { Alert, Button, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material";
import { Cancel, CheckCircle, Warning } from "@mui/icons-material";
import { CippPropertyList } from "../CippComponents/CippPropertyList";
-import { XMarkIcon } from "@heroicons/react/24/outline";
+import { WrenchIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { CippOffCanvas } from "../CippComponents/CippOffCanvas";
import { CippDataTable } from "../CippTable/CippDataTable";
+import { ApiPostCall } from "../../api/ApiCall";
+import { CippApiResults } from "../CippComponents/CippApiResults";
import { useEffect, useState } from "react";
export const CippGDAPResults = (props) => {
const { executeCheck, offcanvasVisible, setOffcanvasVisible, importReport, setCardIcon } = props;
const [results, setResults] = useState({});
+ const repairRoleMappings = ApiPostCall({
+ urlFromData: true,
+ relatedQueryKeys: ["ExecAccessChecks-GDAP"],
+ });
+
+ const handleRepairRoleMappings = () => {
+ repairRoleMappings.mutate({
+ url: "/api/ExecGDAPRepairRoleMappings",
+ data: {},
+ queryKey: "RepairGDAPRoleMappings",
+ });
+ };
+
+ const hasRoleMappingIssues = results?.Results?.RoleMappingResults?.some(
+ (item) => item?.Status === "Stale" || item?.Status === "Missing",
+ );
+
useEffect(() => {
if (importReport) {
setResults(importReport);
@@ -19,7 +38,11 @@ export const CippGDAPResults = (props) => {
}, [executeCheck, importReport]);
useEffect(() => {
- if (results?.Results?.GDAPIssues?.length > 0 || results?.Results?.MissingGroups?.length > 0) {
+ if (
+ results?.Results?.GDAPIssues?.length > 0 ||
+ results?.Results?.MissingGroups?.length > 0 ||
+ hasRoleMappingIssues
+ ) {
setCardIcon( );
} else {
setCardIcon( );
@@ -77,6 +100,15 @@ export const CippGDAPResults = (props) => {
successMessage: "No Global Admin relationships found",
failureMessage: "Global Admin relationships found",
},
+ {
+ resultProperty: "RoleMappingResults",
+ matchProperty: "Status",
+ match: "^(Stale|Missing)$",
+ count: 0,
+ successMessage: "All GDAP role mappings reference existing security groups",
+ failureMessage:
+ "One or more GDAP role mappings reference stale or missing security groups. Click Details to repair.",
+ },
];
const propertyItems = [
@@ -154,13 +186,16 @@ export const CippGDAPResults = (props) => {
}}
extendedInfo={[]}
>
- {results?.Results?.GDAPIssues?.length > 0 && (
+ {results?.Results?.GDAPIssues?.filter((issue) => issue.Category !== "RoleMapping")
+ .length > 0 && (
<>
issue.Category !== "RoleMapping",
+ )}
simpleColumns={["Tenant", "Type", "Issue", "Link"]}
/>
>
@@ -178,6 +213,37 @@ export const CippGDAPResults = (props) => {
>
)}
+ {results?.Results?.RoleMappingResults?.length > 0 && (
+ <>
+
+
+
+
+ }
+ >
+ Repair Role Mappings
+
+ )
+ }
+ data={results?.Results?.RoleMappingResults}
+ simpleColumns={["RoleName", "GroupName", "GroupId", "Status", "Message"]}
+ />
+ >
+ )}
+
{results?.Results?.Memberships?.filter(
(membership) => membership?.["@odata.type"] === "#microsoft.graph.group",
).length > 0 && (
From 0e110a2e416c96f1353c978774c4f3fa1f12e407 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Wed, 17 Jun 2026 22:19:13 +0800
Subject: [PATCH 23/34] Colliding query keys
---
src/components/CippComponents/CippAutocomplete.jsx | 5 ++++-
src/components/CippFormPages/CippAddEditUser.jsx | 2 +-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx
index 075f47ed29a1..04dd59da8e44 100644
--- a/src/components/CippComponents/CippAutocomplete.jsx
+++ b/src/components/CippComponents/CippAutocomplete.jsx
@@ -146,7 +146,10 @@ export const CippAutoComplete = React.forwardRef((props, ref) => {
const currentTenant = api?.tenantFilter ? api.tenantFilter : useSettings().currentTenant
useEffect(() => {
if (actionGetRequest.isSuccess && !actionGetRequest.isFetching) {
- const lastPage = actionGetRequest.data?.pages[actionGetRequest.data.pages.length - 1]
+ // Guard against a non-paginated cache shape (e.g. when a queryKey is accidentally shared
+ // with a useQuery/ApiGetCall consumer that stores a plain array instead of { pages }).
+ const pages = actionGetRequest.data?.pages
+ const lastPage = Array.isArray(pages) ? pages[pages.length - 1] : undefined
const nextLinkExists = lastPage?.Metadata?.nextLink
if (nextLinkExists) {
actionGetRequest.fetchNextPage()
diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx
index 9a3559190372..15a5a52782c1 100644
--- a/src/components/CippFormPages/CippAddEditUser.jsx
+++ b/src/components/CippFormPages/CippAddEditUser.jsx
@@ -50,7 +50,7 @@ const CippAddEditUser = (props) => {
// Get all groups for the tenant
const tenantGroups = ApiGetCall({
url: `/api/ListGroups?tenantFilter=${tenantDomain}`,
- queryKey: `ListGroups-${tenantDomain}`,
+ queryKey: `TenantGroupsList-${tenantDomain}`,
refetchOnMount: false,
refetchOnReconnect: false,
})
From 61ddc970c2db1ea63ec52e3ec0ec4f6b8f385b98 Mon Sep 17 00:00:00 2001
From: Johan Aantjes <47614276+TargetCrafter@users.noreply.github.com>
Date: Wed, 17 Jun 2026 14:38:24 +0000
Subject: [PATCH 24/34] Change "All Deviations" labels to "Selected Deviations"
to prevent confusion.
---
src/pages/tenant/manage/drift.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js
index df2a3869dc38..dd0bf49a6959 100644
--- a/src/pages/tenant/manage/drift.js
+++ b/src/pages/tenant/manage/drift.js
@@ -1948,11 +1948,11 @@ const ManageDriftPage = () => {
onClick={() => handleBulkAction('accept-all-customer-specific')}
>
- Accept All Deviations - Customer Specific
+ Accept Selected Deviations - Customer Specific
handleBulkAction('accept-all')}>
- Accept All Deviations
+ Accept Selected Deviations
{/* Only show delete option if there are template deviations that support deletion */}
{processedDriftData.currentDeviations.some(
@@ -1965,12 +1965,12 @@ const ManageDriftPage = () => {
) && (
handleBulkAction('deny-all-delete')}>
- Deny All Deviations - Delete
+ Deny Selected Deviations - Delete
)}
handleBulkAction('deny-all-remediate')}>
- Deny All Deviations - Remediate to align with template
+ Deny Selected Deviations - Remediate to align with template
From 495f388088cddf2e3cfe0714d99b09a50903f287 Mon Sep 17 00:00:00 2001
From: John Duprey
Date: Wed, 17 Jun 2026 23:05:01 -0400
Subject: [PATCH 25/34] fix: tenant metric grid style
---
.../CippComponents/TenantMetricsGrid.jsx | 25 ++++++++++++-------
1 file changed, 16 insertions(+), 9 deletions(-)
diff --git a/src/components/CippComponents/TenantMetricsGrid.jsx b/src/components/CippComponents/TenantMetricsGrid.jsx
index 323bd44a7f9f..35eda0143286 100644
--- a/src/components/CippComponents/TenantMetricsGrid.jsx
+++ b/src/components/CippComponents/TenantMetricsGrid.jsx
@@ -84,12 +84,13 @@ export const TenantMetricsGrid = ({ data, isLoading }) => {
sx={{
display: "flex",
alignItems: "center",
- gap: 1.5,
- p: 2,
+ gap: { xs: 1, sm: 1.5 },
+ p: { xs: 1, sm: 1.5, md: 2 },
border: 1,
borderColor: "divider",
borderRadius: 1,
cursor: "pointer",
+ minWidth: 0,
transition: "all 0.2s ease-in-out",
"&:hover": {
borderColor: `${metric.color}.main`,
@@ -103,18 +104,24 @@ export const TenantMetricsGrid = ({ data, isLoading }) => {
sx={{
bgcolor: `${metric.color}.main`,
color: `${metric.color}.contrastText`,
- width: 34,
- height: 34,
+ width: { xs: 28, sm: 32, md: 34 },
+ height: { xs: 28, sm: 32, md: 34 },
+ flexShrink: 0,
}}
>
-
+
-
-
+
+
{metric.label}
-
- {isLoading ? : formatNumber(metric.value)}
+
+ {isLoading ? : formatNumber(metric.value)}
From 5e8a4d941dfcfa6dbbfe1b688966587612ad6a19 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Thu, 18 Jun 2026 11:34:16 +0800
Subject: [PATCH 26/34] Update PrivateRoute.js
---
src/components/PrivateRoute.js | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js
index 15b438b2c608..c5adf1d455ee 100644
--- a/src/components/PrivateRoute.js
+++ b/src/components/PrivateRoute.js
@@ -37,9 +37,13 @@ export const PrivateRoute = ({ children, routeType }) => {
waiting: session.isSuccess && session.data?.clientPrincipal !== null,
});
- // If latched as unauthenticated, always show unauthenticated page
+ // If latched as unauthenticated, show the access-denied page — but while a
+ // fresh /.auth/me probe is in flight (e.g. the post-login refetch when the tab
+ // regains focus), show the "logging you in" loading page instead of flashing
+ // access-denied. The latch still holds across idle refetches; only an active
+ // fetch defers to loading.
if (unauthLatched) {
- return ;
+ return session.isFetching ? : ;
}
// Check if the session is still loading before determining authentication status
From f6a0132b9db951218a75eeb86ac61f59f1edaeb2 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Thu, 18 Jun 2026 12:02:18 +0800
Subject: [PATCH 27/34] Update standards.json
---
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 a698010ce141..fda66153955e 100644
--- a/src/data/standards.json
+++ b/src/data/standards.json
@@ -4930,7 +4930,7 @@
"name": "standards.SPFileRequests",
"cat": "SharePoint Standards",
"tag": [],
- "helpText": "Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.",
+ "helpText": "*Requires 'Sharing Level for OneDrive and SharePoint' to be set to Anyone* Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.",
"docsDescription": "File Requests allow users to create secure upload-only share links where uploads are hidden from other people using the link. This creates a secure and private way for people to upload files to a folder. This feature is not enabled by default on new tenants and requires PowerShell configuration. This standard enables or disables this feature and optionally configures link expiration settings for both SharePoint and OneDrive.",
"executiveText": "Enables secure file upload functionality that allows external users to submit files directly to company folders without seeing other submissions or folder contents. This provides a professional and secure way to collect documents from clients, vendors, and partners while maintaining data privacy and security.",
"addedComponent": [
From 5426c48b0492f71a390a10fc90e09a90ce33afe5 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Thu, 18 Jun 2026 18:06:42 +0800
Subject: [PATCH 28/34] spo version cleanup job check
---
src/data/standards.json | 10 +-
src/pages/teams-share/sharepoint/index.js | 136 +++++++++++++++++++++-
2 files changed, 141 insertions(+), 5 deletions(-)
diff --git a/src/data/standards.json b/src/data/standards.json
index fda66153955e..3d27a41de832 100644
--- a/src/data/standards.json
+++ b/src/data/standards.json
@@ -4930,7 +4930,7 @@
"name": "standards.SPFileRequests",
"cat": "SharePoint Standards",
"tag": [],
- "helpText": "*Requires 'Sharing Level for OneDrive and SharePoint' to be set to Anyone* Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.",
+ "helpText": "Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring.",
"docsDescription": "File Requests allow users to create secure upload-only share links where uploads are hidden from other people using the link. This creates a secure and private way for people to upload files to a folder. This feature is not enabled by default on new tenants and requires PowerShell configuration. This standard enables or disables this feature and optionally configures link expiration settings for both SharePoint and OneDrive.",
"executiveText": "Enables secure file upload functionality that allows external users to submit files directly to company folders without seeing other submissions or folder contents. This provides a professional and secure way to collect documents from clients, vendors, and partners while maintaining data privacy and security.",
"addedComponent": [
@@ -8085,8 +8085,12 @@
{
"type": "number",
"name": "standards.SPOVersionControl.ExpireVersionsAfterDays",
- "label": "Expire Versions After Days (0 = never, when auto trim is off)",
- "default": 0
+ "label": "Expire Versions After Days (0 = never, otherwise 30-36500, when auto trim is off)",
+ "default": 0,
+ "validators": {
+ "min": { "value": 0, "message": "Use 0 for never, or 30 or more days" },
+ "max": { "value": 36500, "message": "Maximum value is 36500" }
+ }
},
{
"type": "switch",
diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js
index b39b143c6eee..2c8ae199ee97 100644
--- a/src/pages/teams-share/sharepoint/index.js
+++ b/src/pages/teams-share/sharepoint/index.js
@@ -1,6 +1,6 @@
import { Layout as DashboardLayout } from '../../../layouts/index.js'
import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx'
-import { Button } from '@mui/material'
+import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'
import {
Add,
AddToPhotos,
@@ -10,6 +10,7 @@ import {
NoAccounts,
Delete,
CleaningServices,
+ Assessment,
} from '@mui/icons-material'
import Link from 'next/link'
import { Stack } from '@mui/system'
@@ -18,6 +19,110 @@ import { useSettings } from '../../../hooks/use-settings'
import { useCippReportDB } from '../../../components/CippComponents/CippReportDBControls'
import CippFormComponent from '../../../components/CippComponents/CippFormComponent'
import { CippFormCondition } from '../../../components/CippComponents/CippFormCondition'
+import { CippPropertyList } from '../../../components/CippComponents/CippPropertyList'
+import { ApiGetCall } from '../../../api/ApiCall'
+
+// Friendly labels for the SharePoint version cleanup (trim) job progress fields.
+const VERSION_CLEANUP_LABELS = {
+ Status: 'Status',
+ BatchDeleteMode: 'Cleanup Mode',
+ RequestTimeInUTC: 'Requested (UTC)',
+ LastProcessTimeInUTC: 'Last Processed (UTC)',
+ CompleteTimeInUTC: 'Completed (UTC)',
+ ListsProcessed: 'Lists Processed',
+ ListsUpdated: 'Lists Updated',
+ ListsFailed: 'Lists Failed',
+ FilesProcessed: 'Files Processed',
+ VersionsProcessed: 'Versions Processed',
+ VersionsDeleted: 'Versions Deleted',
+ VersionsFailed: 'Versions Failed',
+ StorageReleased: 'Storage Released (bytes)',
+ ErrorMessage: 'Error Message',
+ WorkItemId: 'Work Item ID',
+}
+// Order in which the fields are shown.
+const VERSION_CLEANUP_FIELDS = Object.keys(VERSION_CLEANUP_LABELS)
+
+// Renders the body of the status modal based on the fetched job progress.
+const VersionCleanupStatusBody = ({ statusApi }) => {
+ const progress = statusApi.data?.Results
+
+ if (statusApi.isError) {
+ return Failed to load cleanup job status.
+ }
+
+ // No job: either an empty/blank response, or the API's explicit "NoRequestFound" status.
+ if (
+ !statusApi.isFetching &&
+ (progress === undefined ||
+ progress === null ||
+ (typeof progress === 'string' && progress.trim() === '') ||
+ progress?.Status === 'NoRequestFound')
+ ) {
+ return No cleanup job found for this site.
+ }
+
+ // Backend couldn't parse the payload and returned the raw string.
+ if (!statusApi.isFetching && typeof progress === 'string') {
+ return {progress}
+ }
+
+ const propertyItems = VERSION_CLEANUP_FIELDS.filter(
+ (key) => progress?.[key] !== undefined && progress?.[key] !== '',
+ ).map((key) => ({
+ label: VERSION_CLEANUP_LABELS[key],
+ value: String(progress[key]),
+ }))
+
+ return (
+ ({ label: VERSION_CLEANUP_LABELS[key], value: '' }))
+ }
+ />
+ )
+}
+
+// Custom-component action modal: opens directly (no confirmation step) and fetches the trim
+// job status for the selected site, rendering it as a property list.
+const VersionCleanupStatusModal = ({ row, tenantFilter, drawerVisible, setDrawerVisible }) => {
+ const siteRow = Array.isArray(row) ? row[0] : row
+ const siteUrl = siteRow?.webUrl
+ const statusApi = ApiGetCall({
+ url: '/api/ListSPOVersionCleanup',
+ data: {
+ tenantFilter: siteRow?.Tenant ?? tenantFilter,
+ SiteUrl: siteUrl,
+ },
+ queryKey: `SPOVersionCleanupStatus-${siteUrl}`,
+ waiting: !!drawerVisible && !!siteUrl,
+ })
+
+ return (
+ setDrawerVisible(false)}
+ >
+
+ Cleanup Job Status{siteRow?.displayName ? ` — ${siteRow.displayName}` : ''}
+
+
+
+
+
+ setDrawerVisible(false)}>
+ Close
+
+
+
+ )
+}
const Page = () => {
const pageTitle = 'SharePoint Sites'
@@ -242,7 +347,10 @@ const Page = () => {
name="DeleteOlderThanDays"
label="Delete Versions Older Than (days)"
formControl={formHook}
- validators={{ required: 'Please enter the number of days' }}
+ validators={{
+ required: 'Please enter the number of days',
+ min: { value: 30, message: 'SharePoint requires at least 30 days' },
+ }}
/>
{
formControl={formHook}
validators={{ required: 'Please enter the version limit' }}
/>
+
>
),
@@ -273,6 +388,10 @@ const Page = () => {
formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1,
MajorVersionLimit:
formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1,
+ MajorWithMinorVersionsLimit:
+ formData.BatchDeleteMode === '1'
+ ? parseInt(formData.MajorWithMinorVersionsLimit, 10)
+ : -1,
})
// When multiple rows are selected, row is an array. Returning an array
// makes CippApiDialog send one request per row (bulk request mode).
@@ -280,6 +399,19 @@ const Page = () => {
},
multiPost: false,
},
+ {
+ label: 'Check Cleanup Job Status',
+ icon: ,
+ customComponent: (row, { drawerVisible, setDrawerVisible }) => (
+
+ ),
+ multiPost: false,
+ },
]
const offCanvas = {
From a79f4100e8566c3f5e231d24be0f24c7d258f8c7 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Thu, 18 Jun 2026 19:48:40 +0800
Subject: [PATCH 29/34] login page tweaks
---
src/components/PrivateRoute.js | 38 ++++++++++++++++++++++++++--------
src/pages/unauthenticated.js | 3 ++-
2 files changed, 31 insertions(+), 10 deletions(-)
diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js
index c5adf1d455ee..98b6ed898ad7 100644
--- a/src/components/PrivateRoute.js
+++ b/src/components/PrivateRoute.js
@@ -2,18 +2,43 @@ 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";
+import { useState, useEffect, useRef } from "react";
+
+const MAX_AUTH_ATTEMPTS = 3;
export const PrivateRoute = ({ children, routeType }) => {
const [unauthLatched, setUnauthLatched] = useState(false);
+ const [authAttempts, setAuthAttempts] = useState(0);
+ const lastSettleRef = useRef(0);
+ const authBudgetExhausted = authAttempts >= MAX_AUTH_ATTEMPTS;
const session = ApiGetCall({
url: "/.auth/me",
queryKey: "authmeswa",
- refetchOnWindowFocus: true,
+ waiting: !authBudgetExhausted,
+ refetchOnWindowFocus: !authBudgetExhausted,
staleTime: 120000, // 2 minutes
});
+ useEffect(() => {
+ const settledAt = Math.max(session.dataUpdatedAt ?? 0, session.errorUpdatedAt ?? 0);
+ if (session.isFetching || settledAt === 0 || settledAt === lastSettleRef.current) {
+ return;
+ }
+ lastSettleRef.current = settledAt;
+ if (session.isSuccess && session.data?.clientPrincipal) {
+ setAuthAttempts(0);
+ } else {
+ setAuthAttempts((n) => Math.min(n + 1, MAX_AUTH_ATTEMPTS));
+ }
+ }, [
+ session.isFetching,
+ session.dataUpdatedAt,
+ session.errorUpdatedAt,
+ session.isSuccess,
+ session.data,
+ ]);
+
// 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(() => {
@@ -37,13 +62,8 @@ export const PrivateRoute = ({ children, routeType }) => {
waiting: session.isSuccess && session.data?.clientPrincipal !== null,
});
- // If latched as unauthenticated, show the access-denied page — but while a
- // fresh /.auth/me probe is in flight (e.g. the post-login refetch when the tab
- // regains focus), show the "logging you in" loading page instead of flashing
- // access-denied. The latch still holds across idle refetches; only an active
- // fetch defers to loading.
- if (unauthLatched) {
- return session.isFetching ? : ;
+ if (unauthLatched || authBudgetExhausted) {
+ return ;
}
// Check if the session is still loading before determining authentication status
diff --git a/src/pages/unauthenticated.js b/src/pages/unauthenticated.js
index 49c6d26861b6..b07fe13bf787 100644
--- a/src/pages/unauthenticated.js
+++ b/src/pages/unauthenticated.js
@@ -15,7 +15,8 @@ const Page = () => {
url: "/.auth/me",
queryKey: "authmeswa",
staleTime: 120000,
- refetchOnWindowFocus: true,
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
});
const blockedRoles = ["anonymous", "authenticated"];
From 539ef35d3259709473805334db1cd91252fcfede Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Thu, 18 Jun 2026 20:20:32 +0800
Subject: [PATCH 30/34] login tweaks
---
src/components/PrivateRoute.js | 45 +++++-----------------------------
src/pages/unauthenticated.js | 3 +--
2 files changed, 7 insertions(+), 41 deletions(-)
diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js
index 98b6ed898ad7..208c11afb8ad 100644
--- a/src/components/PrivateRoute.js
+++ b/src/components/PrivateRoute.js
@@ -2,58 +2,25 @@ 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, useRef } from "react";
-
-const MAX_AUTH_ATTEMPTS = 3;
+import { useState, useEffect } from "react";
export const PrivateRoute = ({ children, routeType }) => {
const [unauthLatched, setUnauthLatched] = useState(false);
- const [authAttempts, setAuthAttempts] = useState(0);
- const lastSettleRef = useRef(0);
- const authBudgetExhausted = authAttempts >= MAX_AUTH_ATTEMPTS;
const session = ApiGetCall({
url: "/.auth/me",
queryKey: "authmeswa",
- waiting: !authBudgetExhausted,
- refetchOnWindowFocus: !authBudgetExhausted,
+ refetchOnWindowFocus: true,
staleTime: 120000, // 2 minutes
});
useEffect(() => {
- const settledAt = Math.max(session.dataUpdatedAt ?? 0, session.errorUpdatedAt ?? 0);
- if (session.isFetching || settledAt === 0 || settledAt === lastSettleRef.current) {
- return;
- }
- lastSettleRef.current = settledAt;
- if (session.isSuccess && session.data?.clientPrincipal) {
- setAuthAttempts(0);
- } else {
- setAuthAttempts((n) => Math.min(n + 1, MAX_AUTH_ATTEMPTS));
- }
- }, [
- session.isFetching,
- session.dataUpdatedAt,
- session.errorUpdatedAt,
- session.isSuccess,
- session.data,
- ]);
-
- // 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)
- ) {
+ if (!session.isLoading && !session.isFetching && !session?.data?.clientPrincipal) {
setUnauthLatched(true);
- } else if (session.isSuccess && session.data?.clientPrincipal) {
+ } else if (session?.data?.clientPrincipal) {
setUnauthLatched(false);
}
- }, [session.isLoading, session.isFetching, session.isError, session.isSuccess, session.data]);
+ }, [session.isLoading, session.isFetching, session.data]);
const apiRoles = ApiGetCall({
url: "/api/me",
@@ -62,7 +29,7 @@ export const PrivateRoute = ({ children, routeType }) => {
waiting: session.isSuccess && session.data?.clientPrincipal !== null,
});
- if (unauthLatched || authBudgetExhausted) {
+ if (unauthLatched) {
return ;
}
diff --git a/src/pages/unauthenticated.js b/src/pages/unauthenticated.js
index b07fe13bf787..49c6d26861b6 100644
--- a/src/pages/unauthenticated.js
+++ b/src/pages/unauthenticated.js
@@ -15,8 +15,7 @@ const Page = () => {
url: "/.auth/me",
queryKey: "authmeswa",
staleTime: 120000,
- refetchOnWindowFocus: false,
- refetchOnMount: false,
+ refetchOnWindowFocus: true,
});
const blockedRoles = ["anonymous", "authenticated"];
From 927714e91120c7c96cd0217c94667ddb888e6874 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Thu, 18 Jun 2026 20:46:44 +0800
Subject: [PATCH 31/34] Sensitivity label fixes
---
.../CippComponents/CippDeployCompliancePolicyDrawer.jsx | 9 ++++++---
src/pages/security/compliance/labels-templates/index.js | 6 +++---
2 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx
index c03c976f9f94..9ca5af4741e1 100644
--- a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx
+++ b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx
@@ -67,10 +67,13 @@ const MODE_CONFIG = {
"Tooltip": "Confidential data, do not share externally",
"Comment": "Internal-only confidential classification",
"ContentType": "File, Email",
+ "ApplyContentMarkingHeaderEnabled": true,
+ "ApplyContentMarkingHeaderText": "Confidential - Internal Use Only",
+ "ApplyContentMarkingHeaderFontColor": "#FF0000",
"EncryptionEnabled": true,
- "EncryptionProtectionType": "Template",
- "ContentMarkingHeaderEnabled": true,
- "ContentMarkingHeaderText": "Confidential - Internal Use Only",
+ "EncryptionProtectionType": "UserDefined",
+ "EncryptionPromptUser": true,
+ "EncryptionDoNotForward": true,
"PolicyParams": {
"Name": "Confidential Label Policy",
"ExchangeLocation": "All",
diff --git a/src/pages/security/compliance/labels-templates/index.js b/src/pages/security/compliance/labels-templates/index.js
index 78477f7735b5..092d076f5370 100644
--- a/src/pages/security/compliance/labels-templates/index.js
+++ b/src/pages/security/compliance/labels-templates/index.js
@@ -72,9 +72,9 @@ const Page = () => {
const offCanvas = {
extendedInfoFields: [
- "name",
"DisplayName",
- "comments",
+ "Name",
+ "Comment",
"ContentType",
"EncryptionEnabled",
"GUID",
@@ -82,7 +82,7 @@ const Page = () => {
actions: actions,
};
- const simpleColumns = ["name", "DisplayName", "comments", "ContentType", "EncryptionEnabled", "GUID"];
+ const simpleColumns = ["DisplayName", "Name", "Comment", "ContentType", "EncryptionEnabled", "GUID"];
return (
Date: Thu, 18 Jun 2026 21:18:22 +0800
Subject: [PATCH 32/34] Update PrivateRoute.js
---
src/components/PrivateRoute.js | 23 +++++++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js
index 208c11afb8ad..821233c1eca8 100644
--- a/src/components/PrivateRoute.js
+++ b/src/components/PrivateRoute.js
@@ -4,6 +4,13 @@ import LoadingPage from "../pages/loading.js";
import ApiOfflinePage from "../pages/api-offline.js";
import { useState, useEffect } from "react";
+// EasyAuth exposes the signed-in identity in two shapes depending on the host:
+// - Static Web Apps: { clientPrincipal: { userDetails, userRoles, ... } }
+// - App Service EasyAuth: [ { user_id, user_claims: [...], access_token, ... } ]
+// an authenticated session must be detected from either populated shape.
+const hasAuthenticatedSession = (data) =>
+ Boolean(data?.clientPrincipal) || (Array.isArray(data) && data.length > 0);
+
export const PrivateRoute = ({ children, routeType }) => {
const [unauthLatched, setUnauthLatched] = useState(false);
@@ -14,21 +21,29 @@ 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. Latch on a request error or a settled session with no identity;
+ // clear it as soon as an authenticated session (either shape) is seen.
useEffect(() => {
- if (!session.isLoading && !session.isFetching && !session?.data?.clientPrincipal) {
+ if (
+ !session.isLoading &&
+ !session.isFetching &&
+ (session.isError || !hasAuthenticatedSession(session.data))
+ ) {
setUnauthLatched(true);
- } else if (session?.data?.clientPrincipal) {
+ } else if (hasAuthenticatedSession(session.data)) {
setUnauthLatched(false);
}
- }, [session.isLoading, session.isFetching, session.data]);
+ }, [session.isLoading, session.isFetching, session.isError, session.data]);
const apiRoles = ApiGetCall({
url: "/api/me",
queryKey: "authmecipp",
retry: 2,
- waiting: session.isSuccess && session.data?.clientPrincipal !== null,
+ waiting: session.isSuccess && hasAuthenticatedSession(session.data),
});
+ // If latched as unauthenticated, always show unauthenticated page
if (unauthLatched) {
return ;
}
From cacd07688de6f43055a550a6d25c4917600d09de Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Thu, 18 Jun 2026 22:12:11 +0800
Subject: [PATCH 33/34] Update cipp-users.js
---
src/pages/cipp/advanced/super-admin/cipp-users.js | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/pages/cipp/advanced/super-admin/cipp-users.js b/src/pages/cipp/advanced/super-admin/cipp-users.js
index 32f0534825b2..ad3b6097c147 100644
--- a/src/pages/cipp/advanced/super-admin/cipp-users.js
+++ b/src/pages/cipp/advanced/super-admin/cipp-users.js
@@ -14,9 +14,15 @@ const Page = () => {
Manage users who can access CIPP. Users are automatically synced from your partner
tenant every 15 minutes based on Entra group memberships configured on the CIPP Roles
page. You can also manually add users or assign additional roles — manual assignments
- are preserved independently and will not be overwritten by the sync. Users not in this
- list can still log in if "Allow All Tenant Users" is enabled, but they will
- only receive default (authenticated) permissions.
+ are preserved independently and will not be overwritten by the sync. Users assigned the
+ superadmin role have full access to CIPP and all other permissions applied will be ignored.
+ You must have at least one superadmin user in CIPP at all times, and you cannot remove the
+ superadmin role from a user if they are the only superadmin. If you have only one superadmin
+ and need to change who it is, first assign another user the superadmin role, then you can
+ remove the superadmin role from the original user. To allow users from outside your partner tenant
+ to access CIPP, you can add them as guest users in your partner tenant and assign them the
+ appropriate roles in CIPP or enable the multi tenant mode in the CIPP SSO tab and add the users
+ to the list below without needing to add them as guest users in your tenant.
From 6fa16c5dd90a5dd8b9ae024a964df9919456d15e Mon Sep 17 00:00:00 2001
From: John Duprey
Date: Thu, 18 Jun 2026 11:46:33 -0400
Subject: [PATCH 34/34] chore: bump version to 10.5.3
---
package.json | 4 ++--
public/version.json | 2 +-
src/layouts/index.js | 1 +
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index 94b04e986410..2f634635ef99 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cipp",
- "version": "10.5.2",
+ "version": "10.5.3",
"author": "CIPP Contributors",
"homepage": "https://cipp.app/",
"bugs": {
@@ -116,4 +116,4 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1"
}
-}
+}
\ No newline at end of file
diff --git a/public/version.json b/public/version.json
index 326768d361ba..1ff943c0e53a 100644
--- a/public/version.json
+++ b/public/version.json
@@ -1,3 +1,3 @@
{
- "version": "10.5.2"
+ "version": "10.5.3"
}
diff --git a/src/layouts/index.js b/src/layouts/index.js
index f3c178556ff3..0ee521c51313 100644
--- a/src/layouts/index.js
+++ b/src/layouts/index.js
@@ -180,6 +180,7 @@ export const Layout = (props) => {
// check sub-items
if (item.items && item.items.length > 0) {
const filteredSubItems = filterItemsByRole(item.items).filter(Boolean)
+ if (filteredSubItems.length === 0) return null
return { ...item, items: filteredSubItems }
}