diff --git a/.github/workflows/cipp_dev_build.yml b/.github/workflows/cipp_dev_build.yml index 394b8bc794f1..c72249f2eff1 100644 --- a/.github/workflows/cipp_dev_build.yml +++ b/.github/workflows/cipp_dev_build.yml @@ -54,7 +54,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.11.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.12.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp diff --git a/.github/workflows/cipp_frontend_build.yml b/.github/workflows/cipp_frontend_build.yml index 5c8d7230e9d3..7e13dd02fa3e 100644 --- a/.github/workflows/cipp_frontend_build.yml +++ b/.github/workflows/cipp_frontend_build.yml @@ -54,7 +54,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.11.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.12.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp diff --git a/package.json b/package.json index f1445743ec47..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": { @@ -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 .", @@ -40,10 +40,10 @@ "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", "@react-pdf/renderer": "^4.5.1", - "@reduxjs/toolkit": "^2.11.2", + "@reduxjs/toolkit": "^2.12.0", "@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", @@ -53,11 +53,11 @@ "@tiptap/react": "^3.20.5", "@tiptap/starter-kit": "^3.20.5", "@vvo/tzdb": "^6.198.0", - "apexcharts": "5.10.4", + "apexcharts": "5.14.0", "axios": "1.16.1", "date-fns": "4.1.0", "diff": "^8.0.3", - "dompurify": "^3.4.3", + "dompurify": "^3.4.9", "driver.js": "^1.4.0", "eml-parse-js": "^1.2.0-beta.0", "export-to-csv": "^1.3.0", @@ -72,7 +72,7 @@ "lodash.isequal": "4.5.0", "material-react-table": "^3.0.1", "monaco-editor": "^0.55.1", - "mui-tiptap": "^1.30.0", + "mui-tiptap": "^1.31.0", "next": "^16.2.2", "nprogress": "0.2.0", "numeral": "2.0.6", @@ -116,4 +116,4 @@ "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.1" } -} +} \ No newline at end of file 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/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/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/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/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/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/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/CippFormLicenseSelector.jsx b/src/components/CippComponents/CippFormLicenseSelector.jsx index cbc6455beaeb..1877b51503fa 100644 --- a/src/components/CippComponents/CippFormLicenseSelector.jsx +++ b/src/components/CippComponents/CippFormLicenseSelector.jsx @@ -32,6 +32,7 @@ export const CippFormLicenseSelector = ({ data: { Endpoint: 'subscribedSkus', $count: true, + IncludeExcluded: true, }, showRefresh, }} diff --git a/src/components/CippComponents/CippIntunePolicyActions.jsx b/src/components/CippComponents/CippIntunePolicyActions.jsx index 09faf1efebf5..fb7363ce14db 100644 --- a/src/components/CippComponents/CippIntunePolicyActions.jsx +++ b/src/components/CippComponents/CippIntunePolicyActions.jsx @@ -1,5 +1,12 @@ import { Book, LaptopChromebook } from '@mui/icons-material' -import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from '@heroicons/react/24/outline' +import { + DocumentDuplicateIcon, + GlobeAltIcon, + PencilIcon, + TrashIcon, + UserIcon, + UserGroupIcon, +} from '@heroicons/react/24/outline' const assignmentModeOptions = [ { label: 'Replace existing assignments', value: 'replace' }, @@ -18,6 +25,8 @@ 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.includeRename - Whether to include the edit name/description 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 +36,8 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => const { platformType = null, includeCreateTemplate = true, + includeRename = true, + includeClone = true, includeDelete = true, deleteUrlName = policyType, templateData = null, @@ -125,6 +136,75 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => }) } + // Edit name and description action + if (includeRename) { + actions.push({ + label: 'Edit Name & Description', + type: 'POST', + url: '/api/EditIntunePolicy', + multiPost: false, + icon: , + color: 'info', + data: { + ID: 'id', + policyType: policyType === 'URLName' ? 'URLName' : policyType, + ...(platformType && { platformType: '!deviceAppManagement' }), + }, + fields: [ + { + type: 'textField', + name: 'newDisplayName', + label: 'Display Name', + }, + { + type: 'textField', + name: 'description', + label: 'Description', + }, + ], + defaultvalues: (row) => ({ + newDisplayName: row.displayName, + description: row.description, + }), + confirmText: 'Enter the new name and description for this policy.', + }) + } + + // 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', 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 */} 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/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/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index e635c5fa988f..2ac784716799 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -131,7 +131,9 @@ const ManageLicensesForm = ({ formControl, tenant }) => { url: '/api/ListLicenses', labelField: (option) => option.displayName || option.skuPartNumber, valueField: 'skuId', + data: { IncludeExcluded: true }, queryKey: `ListLicenses-${tenant}`, + showRefresh: true, }} /> )} @@ -149,7 +151,9 @@ const ManageLicensesForm = ({ formControl, tenant }) => { url: '/api/ListLicenses', labelField: (option) => option.displayName || option.skuPartNumber, valueField: 'skuId', + data: { IncludeExcluded: true }, queryKey: `ListLicenses-${tenant}`, + showRefresh: true, }} /> )} @@ -170,7 +174,9 @@ const ManageLicensesForm = ({ formControl, tenant }) => { option.availableUnits || 0 } available)`, valueField: 'skuId', + data: { IncludeExcluded: true }, queryKey: `ListLicenses-Available-${tenant}`, + showRefresh: true, }} /> )} 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)} 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, }) 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/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/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 && ( 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/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) +
  • + ))} +
    +
    + )} diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index 15b438b2c608..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,27 +21,26 @@ export const PrivateRoute = ({ children, routeType }) => { staleTime: 120000, // 2 minutes }); - // Latch the unauthenticated state so refetches from child components - // don't flip us back to loading. Clear the latch when session succeeds (after login). + // 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.isError || - null === session?.data?.clientPrincipal || - session?.data === undefined) + (session.isError || !hasAuthenticatedSession(session.data)) ) { setUnauthLatched(true); - } else if (session.isSuccess && session.data?.clientPrincipal) { + } else if (hasAuthenticatedSession(session.data)) { setUnauthLatched(false); } - }, [session.isLoading, session.isFetching, session.isError, session.isSuccess, 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 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/data/standards.json b/src/data/standards.json index d0fdad020394..3d27a41de832 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.", @@ -7879,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", @@ -7961,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/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/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/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/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 } } 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 }) }} > 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. 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={ diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index d8b033cbdbd1..f8795293be53 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -4,13 +4,12 @@ import { Layout as DashboardLayout } from '../../../layouts/index.js' import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx' import { Button, SvgIcon, Stack, Box } from '@mui/material' import { TrashIcon } from '@heroicons/react/24/outline' -import { Add, RestartAlt, NotificationsOff } from '@mui/icons-material' +import { Add, RestartAlt, NotificationsOff, Visibility, VisibilityOff } from '@mui/icons-material' import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog' import { useDialog } from '../../../hooks/use-dialog' import CippFormComponent from '../../../components/CippComponents/CippFormComponent' import { CippFormCondition } from '../../../components/CippComponents/CippFormCondition' -import M365LicensesDefault from '../../../data/M365Licenses.json' -import M365LicensesAdditional from '../../../data/M365Licenses-additional.json' +import { getM365Licenses } from '../../../utils/m365-licenses-data' import { useMemo, useCallback } from 'react' const Page = () => { @@ -18,10 +17,10 @@ const Page = () => { const apiUrl = '/api/ListExcludedLicenses' const createDialog = useDialog() const resetDialog = useDialog() - const simpleColumns = ['Product_Display_Name', 'GUID', 'ExclusionType'] + 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) => { @@ -58,6 +57,34 @@ const Page = () => { 'This license will remain visible in CIPP but will be excluded from alerts. Continue?', icon: , }, + { + label: 'Show in License Dropdowns', + type: 'POST', + url: '/api/ExecExcludeLicenses', + data: { + Action: '!SetShowInDropdown', + GUID: 'GUID', + SKUName: 'Product_Display_Name', + ShowInDropdown: true, + }, + confirmText: '[Product_Display_Name] will be available in license dropdowns. Continue?', + icon: , + condition: (row) => row.ShowInLicenseDropdown !== true, + }, + { + label: 'Hide from License Dropdowns', + type: 'POST', + url: '/api/ExecExcludeLicenses', + data: { + Action: '!SetShowInDropdown', + GUID: 'GUID', + SKUName: 'Product_Display_Name', + ShowInDropdown: false, + }, + confirmText: '[Product_Display_Name] will be hidden from license dropdowns. Continue?', + icon: , + condition: (row) => row.ShowInLicenseDropdown === true, + }, { label: 'Delete Exclusion', type: 'POST', @@ -103,7 +130,7 @@ const Page = () => { } const offCanvas = { - extendedInfoFields: ['Product_Display_Name', 'GUID', 'ExclusionType'], + extendedInfoFields: ['Product_Display_Name', 'GUID', 'ExclusionType', 'ShowInLicenseDropdown'], actions: actions, } 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()}` + : ''} + + + + {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 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 && ( 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 ( { + 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}` : ''} + + + + + + + + + ) +} 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' }} /> + ), 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, + 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). + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) + }, + multiPost: false, + }, + { + label: 'Check Cleanup Job Status', + icon: , + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), multiPost: false, }, ] 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", }, }); 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..59b01b6ec7f4 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 } @@ -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 @@ -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') ? [ 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/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 62f5250922df..4967498cac5a 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -124,6 +124,7 @@ const Page = () => { labelField: (option) => `${option.License || option.skuPartNumber} (${option.availableUnits || 0} available)`, valueField: 'skuId', + data: { IncludeExcluded: true }, queryKey: `ListLicenses-${userSettings.currentTenant}`, }, multiple: true, 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(() => {}); diff --git a/yarn.lock b/yarn.lock index c8dbd8c06a1a..7ae67d5020a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1900,10 +1900,10 @@ "@react-spring/shared" "~10.0.3" "@react-spring/types" "~10.0.3" -"@reduxjs/toolkit@^1.9.0 || 2.x.x", "@reduxjs/toolkit@^2.11.2": - version "2.11.2" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82" - integrity sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ== +"@reduxjs/toolkit@^1.9.0 || 2.x.x", "@reduxjs/toolkit@^2.12.0": + version "2.12.0" + resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz#e62787503a38561e04bb8f39e29ca8db689590f9" + integrity sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw== dependencies: "@standard-schema/spec" "^1.0.0" "@standard-schema/utils" "^0.3.0" @@ -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" @@ -2848,10 +2848,10 @@ ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -apexcharts@5.10.4: - version "5.10.4" - resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-5.10.4.tgz#79c9a05ab40b069f33873a1859de6cb0882ccf0e" - integrity sha512-gt0VUqZ2+mr25ScbUcKZgJr96jKYm4vjOcxEWCEh/E5F4dWqhyo3dBhPRvNNnkKiWxkMd2cBwj3ZYH3rK39fkA== +apexcharts@5.14.0: + version "5.14.0" + resolved "https://registry.npmjs.org/apexcharts/-/apexcharts-5.14.0.tgz#01bb15967627dec4638e8ffb516d8ad5eac0e39c" + integrity sha512-hBLz5Gd8B5ZuocEzNUwEeKdw9vd7uy4OnqbDjAYyvwiAEyHk3OnO9+tGzAznpM6pnyCqWjPXUKKYoNIwdskeuQ== argparse@^1.0.7: version "1.0.10" @@ -3742,10 +3742,10 @@ dompurify@3.2.7: optionalDependencies: "@types/trusted-types" "^2.0.7" -dompurify@^3.3.1, dompurify@^3.4.3: - version "3.4.3" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.3.tgz#3ef336e7a757c3bf1efbd3781afb149a3ae5cfa4" - integrity sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A== +dompurify@^3.3.1, dompurify@^3.4.9: + version "3.4.9" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz#b036637b2df8997126b58837bc90f85dbcad6b2f" + integrity sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ== optionalDependencies: "@types/trusted-types" "^2.0.7" @@ -5966,10 +5966,10 @@ ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -mui-tiptap@^1.30.0: - version "1.30.0" - resolved "https://registry.yarnpkg.com/mui-tiptap/-/mui-tiptap-1.30.0.tgz#91257ebb32b12241fe27b24ded42804a3abe2c51" - integrity sha512-BVgv9JstoNsk1SudQuIGV58N7GHlWSdItc8Yxa2BXZ6GFjJ1q1QLFoD6mALRrsKEhxpRfkRHrGaOLlZ5KkO2cQ== +mui-tiptap@^1.31.0: + version "1.31.0" + resolved "https://registry.npmjs.org/mui-tiptap/-/mui-tiptap-1.31.0.tgz#216d0508ca3e9fe145b1f1acda41ccafd8b28944" + integrity sha512-Xcf4tNcNYxFRiwdkRzHihN7f5g8QufvEtWuGH9H/p0Xo+f0k/a8GnIaNpfTdYSwLVeclptWM7IX8BUto8OY96w== dependencies: clsx "^2.1.1" encodeurl "^2.0.0"