diff --git a/.github/workflows/Close_Stale_Issues.yml b/.github/workflows/Close_Stale_Issues.yml index b1878078ac90..ec88789e827d 100644 --- a/.github/workflows/Close_Stale_Issues.yml +++ b/.github/workflows/Close_Stale_Issues.yml @@ -10,8 +10,8 @@ jobs: steps: - uses: actions/stale@v10 with: - stale-issue-message: "This issue is stale because it has been open 10 days with no activity. We will close this issue soon. If you want this feature implemented you can contribute it. See: https://docs.cipp.app/dev-documentation/contributing-to-the-code . Please notify the team if you are working on this yourself." - close-issue-message: "This issue was closed because it has been stalled for 14 days with no activity." + stale-issue-message: "This issue is stale because it has been open for 10 days with no activity. Please do not bump feature requests unless you are actively working on them, as bumps interfere with our triage process and make it harder to maintain a current list of feature requests. If you want this feature implemented, you can contribute it yourself; see https://docs.cipp.app/dev-documentation/contributing-to-the-code. Please notify the team if you are working on this." + close-issue-message: "This issue was closed because it has been stalled for 14 days without activity. We auto-close inactive feature requests to keep the backlog focused and actionable. If this request is still needed, you may submit it again after 30 days." stale-issue-label: "no-activity" exempt-issue-labels: "planned,bug,roadmap" days-before-stale: 9 diff --git a/.github/workflows/Node_Project_Check.yml b/.github/workflows/Node_Project_Check.yml index 1116a307ceb7..3347edfbe84d 100644 --- a/.github/workflows/Node_Project_Check.yml +++ b/.github/workflows/Node_Project_Check.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6.3.0 + uses: actions/setup-node@v6.4.0 with: node-version: ${{ matrix.node-version }} - name: Install and Build Test diff --git a/.github/workflows/cipp_dev_build.yml b/.github/workflows/cipp_dev_build.yml index f0131a4f3692..394b8bc794f1 100644 --- a/.github/workflows/cipp_dev_build.yml +++ b/.github/workflows/cipp_dev_build.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$node_sanitized_version" >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v6.3.0 + uses: actions/setup-node@v6.4.0 with: node-version: ${{ steps.get_node_version.outputs.node_version }} diff --git a/.github/workflows/cipp_frontend_build.yml b/.github/workflows/cipp_frontend_build.yml index 8dedfa497669..5c8d7230e9d3 100644 --- a/.github/workflows/cipp_frontend_build.yml +++ b/.github/workflows/cipp_frontend_build.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$node_sanitized_version" >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v6.3.0 + uses: actions/setup-node@v6.4.0 with: node-version: ${{ steps.get_node_version.outputs.node_version }} diff --git a/.gitignore b/.gitignore index 44dac6dd492c..2758639d59a4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ AGENTS.md # azurite __* AzuriteConfig +# Claude/Cursor worktrees and local AI tooling +.claude/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000000..12937d4c275f --- /dev/null +++ b/.npmrc @@ -0,0 +1,26 @@ +# Supply-chain hardening for CIPP +# This file is honored by BOTH npm and yarn (yarn classic reads .npmrc). +# Any change here should be reviewed for CI/CD impact. + +# Refuse to execute package lifecycle scripts (pre/postinstall, prepare, etc.) +# on dependency install. CIPP has zero of its own lifecycle scripts in +# package.json, so the only scripts this would block are from third-party +# packages — exactly the attack surface we want to close. +ignore-scripts=true + +# Pin the registry explicitly so an inherited per-user .npmrc cannot redirect +# CI / contributor installs to a malicious mirror. +registry=https://registry.npmjs.org/ + +# Require integrity hashes (sha512) to match the lockfile on install. +# npm honors this directly; yarn classic always verifies lockfile integrity +# but this makes the intent explicit. +audit-level=high + +# Don't auto-save changes to the lockfile from arbitrary install commands. +# Lockfile edits should only happen via Dependabot PRs or explicit upgrades. +save-exact=true + +# Disable funding/notifier noise so CI logs only show real signal. +fund=false +update-notifier=false diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 000000000000..c0a88a3c68ee --- /dev/null +++ b/.yarnrc @@ -0,0 +1,18 @@ +# Supply-chain hardening for CIPP (yarn 1 / classic) +# +# This complements .npmrc — yarn 1 honors `ignore-scripts` from .npmrc, but +# we set the per-command equivalents here as defense in depth so the +# protection survives even if .npmrc is missing or ignored. + +# Refuse to execute lifecycle scripts on `yarn install` / `yarn add` / +# `yarn upgrade`. Mirrors `ignore-scripts=true` in .npmrc. +--install.ignore-scripts true +--add.ignore-scripts true +--upgrade.ignore-scripts true + +# Pin the registry so a poisoned per-user .yarnrc cannot redirect installs. +registry "https://registry.npmjs.org/" + +# Disable yarn's self-update check — CI should never auto-update its own +# yarn binary mid-build. +disable-self-update-check true diff --git a/Tests/Shapes/ListTests.json b/Tests/Shapes/ListTests.json index a4553bd14c70..e79c73d8eb9f 100644 --- a/Tests/Shapes/ListTests.json +++ b/Tests/Shapes/ListTests.json @@ -119,6 +119,7 @@ "ExoSafeLinksRules": "number", "ExoSharingPolicy": "number", "ExoTenantAllowBlockList": "number", + "ExoTransportConfig": "number", "ExoTransportRules": "number", "Groups": "number", "Guests": "number", diff --git a/next.config.js b/next.config.js index 97685f34f91e..f2bc28bcd2bb 100644 --- a/next.config.js +++ b/next.config.js @@ -16,6 +16,7 @@ const config = { 'mui-tiptap', 'recharts', '@react-pdf/renderer', + 'lodash', ], webpackMemoryOptimizations: true, preloadEntriesOnStart: false, diff --git a/package.json b/package.json index 57cfa34daf68..45e4b83f482d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.4.5", + "version": "10.5.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -42,55 +42,51 @@ "@react-pdf/renderer": "^4.3.2", "@reduxjs/toolkit": "^2.11.2", "@tanstack/query-sync-storage-persister": "^5.90.25", - "@tanstack/react-query": "^5.96.2", - "@tanstack/react-query-devtools": "^5.51.11", - "@tanstack/react-query-persist-client": "^5.76.0", + "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query-devtools": "^5.96.2", + "@tanstack/react-query-persist-client": "^5.96.2", "@tanstack/react-table": "^8.19.2", - "@tiptap/core": "^3.4.1", + "@tiptap/core": "^3.22.3", "@tiptap/extension-heading": "^3.4.1", - "@tiptap/extension-image": "^3.20.5", - "@tiptap/extension-table": "^3.19.0", + "@tiptap/extension-table": "^3.20.5", "@tiptap/pm": "^3.22.3", "@tiptap/react": "^3.20.5", "@tiptap/starter-kit": "^3.20.5", - "@uiw/react-json-view": "^2.0.0-alpha.41", "@vvo/tzdb": "^6.198.0", "apexcharts": "5.10.4", "axios": "1.15.0", "date-fns": "4.1.0", "diff": "^8.0.3", + "dompurify": "^3.4.3", + "driver.js": "^1.4.0", "eml-parse-js": "^1.2.0-beta.0", "export-to-csv": "^1.3.0", "formik": "2.4.9", "gray-matter": "4.0.3", - "i18next": "25.8.18", "javascript-time-ago": "^2.6.2", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", "leaflet": "^1.9.4", - "leaflet-defaulticon-compatibility": "^0.1.2", "leaflet.markercluster": "^1.5.3", + "lodash": "^4.18.1", "lodash.isequal": "4.5.0", "material-react-table": "^3.0.1", "monaco-editor": "^0.55.1", - "mui-tiptap": "^1.29.1", + "mui-tiptap": "^1.30.0", "next": "^16.2.2", "nprogress": "0.2.0", "numeral": "2.0.6", "prop-types": "15.8.1", "punycode": "^2.3.1", - "react": "19.2.5", + "react": "19.2.6", "react-apexcharts": "2.1.0", "react-beautiful-dnd": "13.1.1", - "react-copy-to-clipboard": "^5.1.0", - "react-dom": "19.2.5", + "react-dom": "19.2.6", "react-dropzone": "15.0.0", "react-error-boundary": "^6.1.1", - "react-grid-layout": "^2.2.3", "react-hook-form": "^7.72.0", "react-hot-toast": "2.6.0", "react-html-parser": "^2.0.2", - "react-i18next": "16.6.5", "react-leaflet": "5.0.0", "react-leaflet-markercluster": "^5.0.0-rc.0", "react-markdown": "10.1.0", @@ -101,28 +97,23 @@ "react-syntax-highlighter": "^16.1.0", "react-time-ago": "^7.3.3", "react-virtuoso": "^4.18.5", - "react-window": "^2.2.7", - "recharts": "^3.7.0", + "recharts": "^3.8.1", "redux": "5.0.1", - "redux-devtools-extension": "2.13.9", "redux-persist": "^6.0.0", - "redux-thunk": "3.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", + "remark-parse": "^11.0.0", "simplebar": "6.3.3", "simplebar-react": "3.3.2", "stylis-plugin-rtl": "2.1.1", - "typescript": "5.9.3", + "unified": "^11.0.5", "yup": "1.7.1" }, "devDependencies": { "@svgr/webpack": "8.1.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", "eslint-config-next": "^16.2.3", "eslint-config-prettier": "^10.1.8", - "prettier": "^3.8.1", - "prettier-eslint": "^16.4.2" + "prettier": "^3.8.1" } } diff --git a/public/assets/integrations/autotask.png b/public/assets/integrations/autotask.png new file mode 100644 index 000000000000..cf2940427613 Binary files /dev/null and b/public/assets/integrations/autotask.png differ diff --git a/public/assets/integrations/connectwise.png b/public/assets/integrations/connectwise.png new file mode 100644 index 000000000000..ef1dfd1234f0 Binary files /dev/null and b/public/assets/integrations/connectwise.png differ diff --git a/public/assets/integrations/kaseya.svg b/public/assets/integrations/kaseya.svg new file mode 100644 index 000000000000..7e34c610d76a --- /dev/null +++ b/public/assets/integrations/kaseya.svg @@ -0,0 +1,3 @@ + + Kaseya BMS + diff --git a/public/version.json b/public/version.json index a09d0fcf2ccd..b7e5a36d3a96 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.4.5" + "version": "10.5.0" } diff --git a/src/components/CippCards/CippExchangeInfoCard.jsx b/src/components/CippCards/CippExchangeInfoCard.jsx index 6a00f53c0248..8bb2737f76c3 100644 --- a/src/components/CippCards/CippExchangeInfoCard.jsx +++ b/src/components/CippCards/CippExchangeInfoCard.jsx @@ -247,7 +247,9 @@ export const CippExchangeInfoCard = (props) => { <> - Auto Expanding Archive: + {exchangeData?.AutoExpandingArchiveScope === 'Organization' + ? 'Auto Expanding Archive: (org)' + : 'Auto Expanding Archive:'} {getCippFormatting( diff --git a/src/components/CippCards/CippPropertyListCard.jsx b/src/components/CippCards/CippPropertyListCard.jsx index 4e7bb2d81f0f..01eb598409da 100644 --- a/src/components/CippCards/CippPropertyListCard.jsx +++ b/src/components/CippCards/CippPropertyListCard.jsx @@ -15,6 +15,7 @@ import { PropertyListItem } from "../../components/property-list-item"; import { useDialog } from "../../hooks/use-dialog"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; import { useState } from "react"; +import { getIconByName } from "../../utils/icon-registry"; export const CippPropertyListCard = (props) => { const { @@ -51,6 +52,12 @@ export const CippPropertyListCard = (props) => { return false; }; + const renderActionIcon = (icon) => { + if (!icon) return null; + if (typeof icon === "string") return getIconByName(icon, { fontSize: "small" }); + return {icon}; + }; + return ( <> @@ -160,7 +167,7 @@ export const CippPropertyListCard = (props) => { actionItems.map((item, index) => ( {item.icon}} + icon={renderActionIcon(item.icon)} label={item.label} onClick={() => { setActionData({ diff --git a/src/components/CippCards/CippRemediationCard.jsx b/src/components/CippCards/CippRemediationCard.jsx index d864719f80e1..4546311a60fa 100644 --- a/src/components/CippCards/CippRemediationCard.jsx +++ b/src/components/CippCards/CippRemediationCard.jsx @@ -1,13 +1,13 @@ -import { Button, Typography, List, ListItem, SvgIcon } from "@mui/material"; -import CippButtonCard from "./CippButtonCard"; // Adjust the import path as needed -import { CippApiDialog } from "../CippComponents/CippApiDialog"; -import { useDialog } from "../../hooks/use-dialog"; -import { Sync } from "@mui/icons-material"; -import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { Button, Typography, List, ListItem, SvgIcon } from '@mui/material' +import CippButtonCard from './CippButtonCard' // Adjust the import path as needed +import { CippApiDialog } from '../CippComponents/CippApiDialog' +import { useDialog } from '../../hooks/use-dialog' +import { Sync } from '@mui/icons-material' +import { ShieldCheckIcon } from '@heroicons/react/24/outline' export default function CippRemediationCard(props) { - const { userPrincipalName, isFetching, userId, tenantFilter, restartProcess } = props; - const createDialog = useDialog(); + const { userPrincipalName, isFetching, userId, tenantFilter, restartProcess } = props + const createDialog = useDialog() return ( Disconnect all current sessions Remove all MFA methods for the user Disable all inbox rules for the user + Disable OneDrive sharing - ); + ) } diff --git a/src/components/CippCards/CippStandardsDialog.jsx b/src/components/CippCards/CippStandardsDialog.jsx index 86de00f07d92..0e006ef43615 100644 --- a/src/components/CippCards/CippStandardsDialog.jsx +++ b/src/components/CippCards/CippStandardsDialog.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import _ from 'lodash' +import { get } from 'lodash' import { Dialog, DialogTitle, @@ -311,7 +311,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan {info.addedComponent.map((component, componentIndex) => { - const value = _.get(templateItem, component.name) + const value = get(templateItem, component.name) let displayValue = 'N/A' if (value) { @@ -427,7 +427,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan let extractedValue = null // Try direct access first - componentValue = _.get(config, component.name) + componentValue = get(config, component.name) // If direct access fails and component name contains dots (nested structure) if ( @@ -441,7 +441,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan if (pathParts[0] === 'standards' && config.standards) { // Remove 'standards.' prefix and try to find the value in config.standards const nestedPath = pathParts.slice(1).join('.') - extractedValue = _.get(config.standards, nestedPath) + extractedValue = get(config.standards, nestedPath) // If still not found, try alternative nested structures // Some standards have double nesting like: config.standards.StandardName.fieldName @@ -452,7 +452,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan ) { const standardName = pathParts[1] const fieldPath = pathParts.slice(2).join('.') - extractedValue = _.get( + extractedValue = get( config.standards, `${standardName}.${fieldPath}` ) diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index 28a53f35ef82..b4591cca26ee 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -20,6 +20,7 @@ import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippBitlockerKeySearch } from "../CippComponents/CippBitlockerKeySearch"; import { nativeMenuItems } from "../../layouts/config"; import { usePermissions } from "../../hooks/use-permissions"; +import { searchLocalLicenseCatalog } from "../../utils/get-cipp-license-catalog"; function getLeafItems(items = []) { let result = []; @@ -40,6 +41,7 @@ async function loadTabOptions() { "/email/administration/exchange-retention", "/cipp/custom-data", "/cipp/advanced/super-admin", + "/endpoint/MEM/enrollment-profiles", "/tenant/standards", "/tenant/manage", "/tenant/administration/applications", @@ -110,6 +112,7 @@ export const CippUniversalSearchV2 = React.forwardRef( { onConfirm = () => {}, onChange = () => {}, + onLicenseSelect, maxResults = 10, value = "", autoFocus = false, @@ -147,6 +150,14 @@ export const CippUniversalSearchV2 = React.forwardRef( waiting: false, }); + // Local-first license lookup. The frontend ships the full Microsoft SKU + // catalog in M365Licenses.json, so for the Licenses type we match locally + // and only fall back to the API when the catalog has no hit. + const localLicenseResults = useMemo(() => { + if (searchType !== "Licenses") return []; + return searchLocalLicenseCatalog(searchValue, maxResults); + }, [searchType, searchValue, maxResults]); + const bitlockerSearch = ApiGetCall({ url: "/api/ExecBitlockerSearch", data: { @@ -271,6 +282,11 @@ export const CippUniversalSearchV2 = React.forwardRef( } else if (searchType === "Pages") { updateDropdownPosition(); setShowDropdown(true); + } else if (searchType === "Licenses") { + // Local catalog is in-memory, so reveal results as the user types. + // The API fallback still requires the Search button (handleSearch). + updateDropdownPosition(); + setShowDropdown(true); } }; @@ -330,7 +346,12 @@ export const CippUniversalSearchV2 = React.forwardRef( const handleSearch = () => { if (searchValue.length > 0) { updateDropdownPosition(); - if (searchType !== "Pages") { + if (searchType === "Licenses") { + // Only hit the API when the local catalog produced nothing. + if (localLicenseResults.length === 0) { + activeSearch?.refetch(); + } + } else if (searchType !== "Pages") { activeSearch?.refetch(); } setShowDropdown(true); @@ -348,8 +369,22 @@ export const CippUniversalSearchV2 = React.forwardRef( router.push( `/identity/administration/groups/group?groupId=${itemData.id}&tenantFilter=${tenantDomain}`, ); + } else if (searchType === "Applications") { + if (match.Type === "Apps") { + router.push( + `/tenant/administration/applications/app-registration?appId=${itemData.appId || itemData.id}&tenantFilter=${tenantDomain}`, + ); + } else { + router.push( + `/tenant/administration/applications/enterprise-app?spId=${itemData.id}&tenantFilter=${tenantDomain}`, + ); + } } else if (searchType === "Pages") { router.push(match.path, undefined, { shallow: true }); + } else if (searchType === "Licenses") { + if (typeof onLicenseSelect === "function") { + onLicenseSelect(itemData); + } } setSearchValue(""); setShowDropdown(false); @@ -381,7 +416,7 @@ export const CippUniversalSearchV2 = React.forwardRef( const typeMenuActions = [ { label: "Users", - icon: "UsersIcon", + icon: "Groups", onClick: () => handleTypeChange("Users"), }, { @@ -389,6 +424,16 @@ export const CippUniversalSearchV2 = React.forwardRef( icon: "Group", onClick: () => handleTypeChange("Groups"), }, + { + label: "Applications", + icon: "Apps", + onClick: () => handleTypeChange("Applications"), + }, + { + label: "Licenses", + icon: "VpnKey", + onClick: () => handleTypeChange("Licenses"), + }, { label: "BitLocker", icon: "FilePresent", @@ -396,7 +441,7 @@ export const CippUniversalSearchV2 = React.forwardRef( }, { label: "Pages", - icon: "GlobeAltIcon", + icon: "Public", onClick: () => handleTypeChange("Pages"), }, ]; @@ -482,18 +527,28 @@ export const CippUniversalSearchV2 = React.forwardRef( ? bitlockerSearch.data.Results : []; const universalResults = Array.isArray(universalSearch?.data) ? universalSearch.data : []; + const licenseResults = + searchType === "Licenses" + ? localLicenseResults.length > 0 + ? localLicenseResults + : universalResults + : universalResults; const activeResults = searchType === "BitLocker" ? bitlockerResults : searchType === "Pages" ? pageResults - : universalResults; + : searchType === "Licenses" + ? licenseResults + : universalResults; const hasResults = searchType === "BitLocker" ? bitlockerResults.length > 0 : searchType === "Pages" ? pageResults.length > 0 - : universalResults.length > 0; + : searchType === "Licenses" + ? licenseResults.length > 0 + : universalResults.length > 0; const shouldShowDropdown = showDropdown && searchValue.length > 0; const getLabel = () => { @@ -507,6 +562,8 @@ export const CippUniversalSearchV2 = React.forwardRef( : "Search BitLocker by Recovery Key ID"; } else if (searchType === "Pages") { return "Search pages, tabs, paths, or scope"; + } else if (searchType === "Licenses") { + return "Search licenses by SKU ID, part number, name, or service plan"; } return "Search"; }; @@ -617,7 +674,7 @@ export const CippUniversalSearchV2 = React.forwardRef( /> ) : ( p?.servicePlanName) + .filter(Boolean) + .join(", "); + return ( + onResultClick(match)} + onMouseEnter={() => setHighlightedIndex(index)} + selected={highlightedIndex === index} + sx={{ + py: 1.5, + px: 2, + borderBottom: index < items.length - 1 ? "1px solid" : "none", + borderColor: "divider", + alignItems: "flex-start", + whiteSpace: "normal", + backgroundColor: highlightedIndex === index ? "action.selected" : "transparent", + "&:hover": { backgroundColor: "action.hover" }, + }} + > + + + {highlightMatch(itemData.displayName || itemData.skuPartNumber || "Unknown SKU")} + + {itemData.skuPartNumber && ( + + {highlightMatch(itemData.skuPartNumber)} + + )} + + } + secondary={ + + {itemData.skuId && ( + + {highlightMatch(itemData.skuId)} + + )} + + {itemData.tenantCount || 0} tenant{itemData.tenantCount === 1 ? "" : "s"} + {" · "} + {itemData.totalAssigned || 0}/{itemData.totalAvailable || 0} assigned + {servicePlans.length > 0 && ` · ${servicePlans.length} service plan${servicePlans.length === 1 ? "" : "s"}`} + + {planNames && ( + + {highlightMatch(planNames)} + + )} + + } + /> + + ); + } + return ( )} + {searchType === "Applications" && ( + <> + {itemData.appId && ( + + {highlightMatch(itemData.appId || "")} + + )} + {itemData.publisherName && ( + + {highlightMatch(itemData.publisherName || "")} + + )} + + )} { - const [authInProgress, setAuthInProgress] = useState(false); - const [authError, setAuthError] = useState(null); - const [deviceCodeInfo, setDeviceCodeInfo] = useState(null); - const [codeRetrievalInProgress, setCodeRetrievalInProgress] = useState(false); - const [isServiceAccount, setIsServiceAccount] = useState(true); - const [promptDialog, setPromptDialog] = useState({ open: false }); + const [authInProgress, setAuthInProgress] = useState(false) + const [authError, setAuthError] = useState(null) + const [deviceCodeInfo, setDeviceCodeInfo] = useState(null) + const [codeRetrievalInProgress, setCodeRetrievalInProgress] = useState(false) + const [isServiceAccount, setIsServiceAccount] = useState(true) + const [promptDialog, setPromptDialog] = useState({ open: false }) const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null, @@ -32,84 +32,84 @@ export const CIPPM365OAuthButton = ({ username: null, tenantId: null, onmicrosoftDomain: null, - }); + }) const appIdInfo = ApiGetCall({ url: `/api/ExecListAppId`, - queryKey: "listAppId", + queryKey: 'listAppId', waiting: true, - }); + }) const handleCloseError = () => { - setAuthError(null); - }; + setAuthError(null) + } const checkIsServiceAccount = (username) => { - if (!username || !validateServiceAccount) return true; // If no username or validation disabled, don't show warning + if (!username || !validateServiceAccount) return true // If no username or validation disabled, don't show warning - const lowerUsername = username.toLowerCase(); - return lowerUsername.includes("service") || lowerUsername.includes("cipp"); - }; + const lowerUsername = username.toLowerCase() + return lowerUsername.includes('service') || lowerUsername.includes('cipp') + } // Function to retrieve device code const retrieveDeviceCode = async () => { - setCodeRetrievalInProgress(true); - setAuthError(null); + setCodeRetrievalInProgress(true) + setAuthError(null) // Only refetch appId if not already present if (!applicationId && !appIdInfo?.data?.applicationId) { - await appIdInfo.refetch(); + await appIdInfo.refetch() } try { // Get the application ID to use const appId = - applicationId || appIdInfo?.data?.applicationId || "1b730954-1685-4b74-9bfd-dac224a7b894"; // Default to MS Graph Explorer app ID + applicationId || appIdInfo?.data?.applicationId || '1b730954-1685-4b74-9bfd-dac224a7b894' // Default to MS Graph Explorer app ID // Request device code from our API endpoint const deviceCodeResponse = await fetch( `/api/ExecDeviceCodeLogon?operation=getDeviceCode&clientId=${appId}&scope=${encodeURIComponent( - scope, - )}`, - ); - const deviceCodeData = await deviceCodeResponse.json(); + scope + )}` + ) + const deviceCodeData = await deviceCodeResponse.json() if (deviceCodeResponse.ok && deviceCodeData.user_code) { // Store device code info - setDeviceCodeInfo(deviceCodeData); + setDeviceCodeInfo(deviceCodeData) } else { // Error getting device code setAuthError({ - errorCode: deviceCodeData.error || "device_code_error", - errorMessage: deviceCodeData.error_description || "Failed to get device code", + errorCode: deviceCodeData.error || 'device_code_error', + errorMessage: deviceCodeData.error_description || 'Failed to get device code', timestamp: new Date().toISOString(), - }); + }) } } catch (error) { setAuthError({ - errorCode: "device_code_error", - errorMessage: error.message || "An error occurred retrieving device code", + errorCode: 'device_code_error', + errorMessage: error.message || 'An error occurred retrieving device code', timestamp: new Date().toISOString(), - }); + }) } finally { - setCodeRetrievalInProgress(false); + setCodeRetrievalInProgress(false) } - }; + } // Device code authentication function - opens popup and starts polling const handleDeviceCodeAuthentication = async () => { // Only refetch appId if not already present if (!applicationId && !appIdInfo?.data?.applicationId) { - await appIdInfo.refetch(); + await appIdInfo.refetch() } if (!deviceCodeInfo) { // If we don't have a device code yet, retrieve it first - await retrieveDeviceCode(); - return; + await retrieveDeviceCode() + return } - setAuthInProgress(true); + setAuthInProgress(true) setTokens({ accessToken: null, refreshToken: null, @@ -118,131 +118,131 @@ export const CIPPM365OAuthButton = ({ username: null, tenantId: null, onmicrosoftDomain: null, - }); + }) try { // Get the application ID to use - refetch already happened at the start of this function const appId = - applicationId || appIdInfo?.data?.applicationId || "1b730954-1685-4b74-9bfd-dac224a7b894"; // Default to MS Graph Explorer app ID + applicationId || appIdInfo?.data?.applicationId || '1b730954-1685-4b74-9bfd-dac224a7b894' // Default to MS Graph Explorer app ID // Open popup to device login page - const width = 500; - const height = 600; - const left = window.screen.width / 2 - width / 2; - const top = window.screen.height / 2 - height / 2; + const width = 500 + const height = 600 + const left = window.screen.width / 2 - width / 2 + const top = window.screen.height / 2 - height / 2 const popup = window.open( - "https://microsoft.com/devicelogin", - "deviceLoginPopup", - `width=${width},height=${height},left=${left},top=${top}`, - ); + 'https://microsoft.com/devicelogin', + 'deviceLoginPopup', + `width=${width},height=${height},left=${left},top=${top}` + ) // Start polling for token - const pollInterval = deviceCodeInfo.interval || 5; - const expiresIn = deviceCodeInfo.expires_in || 900; - const startTime = Date.now(); + const pollInterval = deviceCodeInfo.interval || 5 + const expiresIn = deviceCodeInfo.expires_in || 900 + const startTime = Date.now() const pollForToken = async () => { // Check if we've exceeded the expiration time if (Date.now() - startTime >= expiresIn * 1000) { if (popup && !popup.closed) { - popup.close(); + popup.close() } setAuthError({ - errorCode: "timeout", - errorMessage: "Device code authentication timed out", + errorCode: 'timeout', + errorMessage: 'Device code authentication timed out', timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); - return; + }) + setAuthInProgress(false) + return } try { // Poll for token using our API endpoint const tokenResponse = await fetch( - `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}`, - ); - const tokenData = await tokenResponse.json(); + `/api/ExecDeviceCodeLogon?operation=checkToken&clientId=${appId}&deviceCode=${deviceCodeInfo.device_code}` + ) + const tokenData = await tokenResponse.json() - if (tokenResponse.ok && tokenData.status === "success") { + if (tokenResponse.ok && tokenData.status === 'success') { // Successfully got token if (popup && !popup.closed) { - popup.close(); + popup.close() } - handleTokenResponse(tokenData); + handleTokenResponse(tokenData) } else if ( - tokenData.error === "authorization_pending" || - tokenData.status === "pending" + tokenData.error === 'authorization_pending' || + tokenData.status === 'pending' ) { // User hasn't completed authentication yet, continue polling - setTimeout(pollForToken, pollInterval * 1000); - } else if (tokenData.error === "slow_down") { + setTimeout(pollForToken, pollInterval * 1000) + } else if (tokenData.error === 'slow_down') { // Server asking us to slow down polling - setTimeout(pollForToken, (pollInterval + 5) * 1000); + setTimeout(pollForToken, (pollInterval + 5) * 1000) } else { // Other error if (popup && !popup.closed) { - popup.close(); + popup.close() } setAuthError({ - errorCode: tokenData.error || "token_error", - errorMessage: tokenData.error_description || "Failed to get token", + errorCode: tokenData.error || 'token_error', + errorMessage: tokenData.error_description || 'Failed to get token', timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); + }) + setAuthInProgress(false) } } catch (error) { - setTimeout(pollForToken, pollInterval * 1000); + setTimeout(pollForToken, pollInterval * 1000) } - }; + } // Start polling - setTimeout(pollForToken, pollInterval * 1000); + setTimeout(pollForToken, pollInterval * 1000) } catch (error) { setAuthError({ - errorCode: "device_code_error", - errorMessage: error.message || "An error occurred during device code authentication", + errorCode: 'device_code_error', + errorMessage: error.message || 'An error occurred during device code authentication', timestamp: new Date().toISOString(), - }); - setAuthInProgress(false); + }) + setAuthInProgress(false) } - }; + } // Process token response (common for both auth methods) const handleTokenResponse = (tokenData) => { // Extract token information - const accessTokenExpiresOn = new Date(Date.now() + tokenData.expires_in * 1000); + const accessTokenExpiresOn = new Date(Date.now() + tokenData.expires_in * 1000) // Refresh tokens typically last for 90 days, but this can vary - const refreshTokenExpiresOn = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); + const refreshTokenExpiresOn = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // Extract information from ID token if available - let username = "unknown user"; - let tenantId = "unknown tenant"; - let onmicrosoftDomain = null; + let username = 'unknown user' + let tenantId = 'unknown tenant' + let onmicrosoftDomain = null if (tokenData.id_token) { try { - const idTokenPayload = JSON.parse(atob(tokenData.id_token.split(".")[1])); + const idTokenPayload = JSON.parse(atob(tokenData.id_token.split('.')[1])) username = idTokenPayload.preferred_username || idTokenPayload.email || idTokenPayload.upn || idTokenPayload.name || - "unknown user"; + 'unknown user' if (idTokenPayload.tid) { - tenantId = idTokenPayload.tid; + tenantId = idTokenPayload.tid } - if (username && username.includes("@") && username.includes(".onmicrosoft.com")) { - onmicrosoftDomain = username.split("@")[1]; + if (username && username.includes('@') && username.includes('.onmicrosoft.com')) { + onmicrosoftDomain = username.split('@')[1] } else if (idTokenPayload.iss) { - const issuerMatch = idTokenPayload.iss.match(/https:\/\/sts\.windows\.net\/([^/]+)\//); + const issuerMatch = idTokenPayload.iss.match(/https:\/\/sts\.windows\.net\/([^/]+)\//) if (issuerMatch && issuerMatch[1]) { } } - setIsServiceAccount(checkIsServiceAccount(username)); + setIsServiceAccount(checkIsServiceAccount(username)) } catch (error) {} } @@ -255,25 +255,25 @@ export const CIPPM365OAuthButton = ({ username: username, tenantId: tenantId, onmicrosoftDomain: onmicrosoftDomain, - }; + } - setTokens(tokenResult); - setDeviceCodeInfo(null); + setTokens(tokenResult) + setDeviceCodeInfo(null) - if (onAuthSuccess) onAuthSuccess(tokenResult); + if (onAuthSuccess) onAuthSuccess(tokenResult) // Update UI state - setAuthInProgress(false); - setIsServiceAccount(checkIsServiceAccount(username)); - }; + setAuthInProgress(false) + setIsServiceAccount(checkIsServiceAccount(username)) + } // MSAL-like authentication function const handleMsalAuthentication = async (retryCount = 0) => { - const maxRetries = 3; + const maxRetries = 3 // Clear previous authentication state when starting a new authentication - setAuthInProgress(true); - setAuthError(null); + setAuthInProgress(true) + setAuthError(null) setTokens({ accessToken: null, refreshToken: null, @@ -282,15 +282,15 @@ export const CIPPM365OAuthButton = ({ username: null, tenantId: null, onmicrosoftDomain: null, - }); + }) // Only refetch app ID if not already present if (!applicationId && !appIdInfo?.data?.applicationId) { - await appIdInfo.refetch(); + await appIdInfo.refetch() } // Get the application ID to use - const appId = applicationId || appIdInfo?.data?.applicationId; + const appId = applicationId || appIdInfo?.data?.applicationId // Generate MSAL-like authentication parameters const msalConfig = { @@ -299,23 +299,23 @@ export const CIPPM365OAuthButton = ({ authority: `https://login.microsoftonline.com/common`, redirectUri: `${window.location.origin}/authredirect`, }, - }; + } // Define the request object similar to MSAL const loginRequest = { scopes: [scope], - }; + } // Generate PKCE code verifier and challenge const generateCodeVerifier = () => { - const array = new Uint8Array(32); - window.crypto.getRandomValues(array); - return Array.from(array, (byte) => ("0" + (byte & 0xff).toString(16)).slice(-2)).join(""); - }; - - const codeVerifier = generateCodeVerifier(); - const codeChallenge = codeVerifier; - const state = Math.random().toString(36).substring(2, 15); + const array = new Uint8Array(32) + window.crypto.getRandomValues(array) + return Array.from(array, (byte) => ('0' + (byte & 0xff).toString(16)).slice(-2)).join('') + } + + const codeVerifier = generateCodeVerifier() + const codeChallenge = codeVerifier + const state = Math.random().toString(36).substring(2, 15) const authUrl = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` + `client_id=${appId}` + @@ -325,96 +325,93 @@ export const CIPPM365OAuthButton = ({ `&code_challenge=${codeChallenge}` + `&code_challenge_method=plain` + `&state=${state}` + - `&prompt=select_account`; + `&prompt=select_account` - // Open popup for authentication - const width = 500; - const height = 600; - const left = window.screen.width / 2 - width / 2; - const top = window.screen.height / 2 - height / 2; + // Open a blank popup first, then navigate it. This keeps the window reference stable and + // avoids treating slow Microsoft page loads as an immediate user cancellation. + const width = 500 + const height = 600 + const left = window.screen.width / 2 - width / 2 + const top = window.screen.height / 2 - height / 2 - const popup = window.open( - authUrl, - "msalAuthPopup", - `width=${width},height=${height},left=${left},top=${top}`, - ); + window.open(authUrl, 'msalAuthPopup', `width=${width},height=${height},left=${left},top=${top}`) // Function to actually exchange the authorization code for tokens const handleAuthorizationCode = async (code, receivedState) => { // Verify the state parameter matches what we sent (security check) if (receivedState !== state) { - const errorMessage = "State mismatch in auth response - possible CSRF attack"; + const errorMessage = 'State mismatch in auth response - possible CSRF attack' const error = { - errorCode: "state_mismatch", + errorCode: 'state_mismatch', errorMessage: errorMessage, timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); - setAuthInProgress(false); - return; + } + setAuthError(error) + if (onAuthError) onAuthError(error) + setAuthInProgress(false) + return } try { // Prepare the token request const tokenRequest = { - grant_type: "authorization_code", + grant_type: 'authorization_code', client_id: appId, code: code, redirect_uri: `${window.location.origin}/authredirect`, code_verifier: codeVerifier, - }; + } // Make the token request through our API proxy to avoid origin header issues // Retry logic for AADSTS650051 (service principal already exists) - let retryCount = 0; - const maxRetries = 3; - let tokenResponse; - let tokenData; + let retryCount = 0 + const maxRetries = 3 + let tokenResponse + let tokenData while (retryCount <= maxRetries) { tokenResponse = await fetch(`/api/ExecTokenExchange`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ tokenRequest, - tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', tenantId: appId, // Pass the tenant ID to retrieve the correct client secret }), - }); + }) // Parse the token response - tokenData = await tokenResponse.json(); + tokenData = await tokenResponse.json() // Check if it's the AADSTS650051 error (service principal already exists) if ( - tokenData.error === "invalid_client" && - tokenData.error_description?.includes("AADSTS650051") + tokenData.error === 'invalid_client' && + tokenData.error_description?.includes('AADSTS650051') ) { - retryCount++; + retryCount++ if (retryCount <= maxRetries) { // Wait before retrying (exponential backoff) - await new Promise((resolve) => setTimeout(resolve, 2000 * retryCount)); - continue; + await new Promise((resolve) => setTimeout(resolve, 2000 * retryCount)) + continue } } // If no error or different error, break out of retry loop - break; + break } // Check if the response contains an error if (tokenData.error) { const error = { - errorCode: tokenData.error || "token_error", + errorCode: tokenData.error || 'token_error', errorMessage: - tokenData.error_description || "Failed to exchange authorization code for tokens", + tokenData.error_description || 'Failed to exchange authorization code for tokens', timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); - setAuthInProgress(false); - return; + } + setAuthError(error) + if (onAuthError) onAuthError(error) + setAuthInProgress(false) + return } if (tokenResponse.ok) { @@ -422,13 +419,13 @@ export const CIPPM365OAuthButton = ({ if (tokenData.refresh_token) { try { // Extract tid from access_token jwt base64 - const accessTokenParts = tokenData.access_token.split("."); - const accessTokenPayload = JSON.parse(atob(accessTokenParts[1] || "")); - tokenData.tid = accessTokenPayload.tid; + const accessTokenParts = tokenData.access_token.split('.') + const accessTokenPayload = JSON.parse(atob(accessTokenParts[1] || '')) + tokenData.tid = accessTokenPayload.tid const refreshResponse = await fetch(`/api/ExecUpdateRefreshToken`, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ tenantId: tokenData.tid, @@ -436,149 +433,104 @@ export const CIPPM365OAuthButton = ({ tenantMode: tokenData.tenantMode, allowPartnerTenantManagement: tokenData.allowPartnerTenantManagement, }), - }); + }) if (!refreshResponse.ok) { - console.warn("Failed to store refresh token, but continuing with authentication"); + console.warn('Failed to store refresh token, but continuing with authentication') } else { // Invalidate the listAppId and tenants-table queryKeys to refresh data - appIdInfo.refetch(); + appIdInfo.refetch() } } catch (error) { - console.error("Failed to store refresh token:", error); + console.error('Failed to store refresh token:', error) } } - handleTokenResponse(tokenData); + handleTokenResponse(tokenData) } else { // Handle token error - display in error box instead of throwing const error = { - errorCode: tokenData.error || "token_error", + errorCode: tokenData.error || 'token_error', errorMessage: - tokenData.error_description || "Failed to exchange authorization code for tokens", + tokenData.error_description || 'Failed to exchange authorization code for tokens', timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); + } + setAuthError(error) + if (onAuthError) onAuthError(error) } } catch (error) { const errorObj = { - errorCode: "token_exchange_error", - errorMessage: error.message || "Failed to exchange authorization code for tokens", + errorCode: 'token_exchange_error', + errorMessage: error.message || 'Failed to exchange authorization code for tokens', timestamp: new Date().toISOString(), - }; - setAuthError(errorObj); - if (onAuthError) onAuthError(errorObj); - } finally { - // Close the popup window if it's still open - if (popup && !popup.closed) { - popup.close(); } - + setAuthError(errorObj) + if (onAuthError) onAuthError(errorObj) + } finally { // Update UI state - setAuthInProgress(false); + setAuthInProgress(false) } - }; + } - // Monitor for the redirect with the authorization code - // This is what MSAL does internally - const checkPopupLocation = setInterval(() => { - if (!popup || popup.closed) { - clearInterval(checkPopupLocation); + // Listen for auth result via BroadcastChannel (works regardless of COOP) + const channel = new BroadcastChannel('cipp_auth') - // If authentication is still in progress when popup closes, it's an error - if (authInProgress) { - const errorMessage = "Authentication was cancelled. Please try again."; - const error = { - errorCode: "user_cancelled", - errorMessage: errorMessage, - timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); - - // Ensure we're not showing any previous success state - setTokens({ - accessToken: null, - refreshToken: null, - accessTokenExpiresOn: null, - refreshTokenExpiresOn: null, - username: null, - tenantId: null, - onmicrosoftDomain: null, - }); - } - - setAuthInProgress(false); - return; + const authTimeout = setTimeout(() => { + // If no response after 10 minutes, treat as cancelled + cleanup() + const error = { + errorCode: 'timeout', + errorMessage: 'Authentication timed out. Please try again.', + timestamp: new Date().toISOString(), } - - try { - // Try to access the popup location to check for the authorization code - const currentUrl = popup.location.href; - - // Check if the URL contains a code parameter (authorization code) - if (currentUrl.includes("code=") && currentUrl.includes("state=")) { - clearInterval(checkPopupLocation); - // Parse the URL to extract the code and state - const urlParams = new URLSearchParams(popup.location.search); - const code = urlParams.get("code"); - const receivedState = urlParams.get("state"); - - // Process the authorization code - handleAuthorizationCode(code, receivedState); + setAuthError(error) + if (onAuthError) onAuthError(error) + setTokens({ + accessToken: null, + refreshToken: null, + accessTokenExpiresOn: null, + refreshTokenExpiresOn: null, + username: null, + tenantId: null, + onmicrosoftDomain: null, + }) + setAuthInProgress(false) + }, 600000) + + channel.onmessage = (event) => { + if (event.data?.type === 'auth_code') { + cleanup() + handleAuthorizationCode(event.data.code, event.data.state) + } else if (event.data?.type === 'auth_error') { + cleanup() + + // Check if it's the AADSTS650051 error (service principal already exists during consent) + if ( + event.data.error === 'invalid_client' && + event.data.errorDescription?.includes('AADSTS650051') && + retryCount < maxRetries + ) { + setAuthInProgress(false) + setTimeout(() => handleMsalAuthentication(retryCount + 1), 2000 * (retryCount + 1)) + return } - // Check for error in the URL - if (currentUrl.includes("error=")) { - clearInterval(checkPopupLocation); - // Parse the URL to extract the error details - const urlParams = new URLSearchParams(popup.location.search); - const errorCode = urlParams.get("error"); - const errorDescription = urlParams.get("error_description"); - - // Check if it's the AADSTS650051 error (service principal already exists during consent) - if ( - errorCode === "invalid_client" && - errorDescription?.includes("AADSTS650051") && - retryCount < maxRetries - ) { - // Close the popup - popup.close(); - setAuthInProgress(false); - - // Wait before retrying (exponential backoff) - setTimeout( - () => { - handleMsalAuthentication(retryCount + 1); - }, - 2000 * (retryCount + 1), - ); - return; - } - - // Set the error state for non-retryable errors - const error = { - errorCode: errorCode, - errorMessage: errorDescription || "Unknown authentication error", - timestamp: new Date().toISOString(), - }; - setAuthError(error); - if (onAuthError) onAuthError(error); - - // Close the popup - popup.close(); - setAuthInProgress(false); + const error = { + errorCode: event.data.error || 'auth_error', + errorMessage: event.data.errorDescription || 'Unknown authentication error', + timestamp: new Date().toISOString(), } - } catch (error) { - // This will throw an error when the popup is on a different domain - // due to cross-origin restrictions, which is normal during auth flow - // Just continue monitoring + setAuthError(error) + if (onAuthError) onAuthError(error) + setAuthInProgress(false) } - }, 500); + } - // Also monitor for popup closing as a fallback - }; + const cleanup = () => { + channel.close() + clearTimeout(authTimeout) + } + } // Auto-start device code retrieval if requested useEffect(() => { @@ -590,7 +542,7 @@ export const CIPPM365OAuthButton = ({ !tokens.accessToken && appIdInfo?.data ) { - retrieveDeviceCode(); + retrieveDeviceCode() } }, [ useDeviceCode, @@ -599,7 +551,7 @@ export const CIPPM365OAuthButton = ({ deviceCodeInfo, tokens.accessToken, appIdInfo?.data, - ]); + ]) return (
@@ -607,7 +559,7 @@ export const CIPPM365OAuthButton = ({ !appIdInfo.isLoading && appIdInfo?.data?.applicationId && // Only check if applicationId is present in data !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - appIdInfo?.data?.applicationId, + appIdInfo?.data?.applicationId ) && ( The Application ID is not valid. Please check your configuration. @@ -622,14 +574,14 @@ export const CIPPM365OAuthButton = ({ {authInProgress ? ( <> - When asked to log onto an account, please use a{" "} - CIPP Service Account. Enter this code to authenticate:{" "} + When asked to log onto an account, please use a{' '} + CIPP Service Account. Enter this code to authenticate:{' '} ) : ( <> Click the button below to authenticate. When asked to log onto an account, please use a CIPP Service Account. You will need to enter this - code:{" "} + code:{' '} )} @@ -637,13 +589,13 @@ export const CIPPM365OAuthButton = ({ {authInProgress ? ( <> - If the popup was blocked or you closed it, you can also go to{" "} + If the popup was blocked or you closed it, you can also go to{' '} microsoft.com/devicelogin manually and enter the code shown above. ) : ( <> - When you click the button below, a popup will open to{" "} + When you click the button below, a popup will open to{' '} microsoft.com/devicelogin where you'll enter this code. )} @@ -665,7 +617,7 @@ export const CIPPM365OAuthButton = ({ Tenant ID: {tokens.tenantId} {tokens.onmicrosoftDomain && ( <> - {" "} + {' '} | Domain: {tokens.onmicrosoftDomain} )} @@ -711,21 +663,21 @@ export const CIPPM365OAuthButton = ({ {promptBeforeAuth !== false && ( setPromptDialog({ open: false }), }} api={{ - type: "POST", + type: 'POST', confirmText: promptBeforeAuth, noConfirm: false, customFunction: () => { - setPromptDialog({ open: false }); + setPromptDialog({ open: false }) const authFunction = useDeviceCode ? handleDeviceCodeAuthentication - : handleMsalAuthentication; - authFunction(); + : handleMsalAuthentication + authFunction() }, }} fields={[]} @@ -740,17 +692,17 @@ export const CIPPM365OAuthButton = ({ codeRetrievalInProgress || (!applicationId && !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - appIdInfo?.data?.applicationId, + appIdInfo?.data?.applicationId )) } onClick={() => { if (promptBeforeAuth !== false) { - setPromptDialog({ open: true }); + setPromptDialog({ open: true }) } else { const authFunction = useDeviceCode ? handleDeviceCodeAuthentication - : handleMsalAuthentication; - authFunction(); + : handleMsalAuthentication + authFunction() } }} color="primary" @@ -765,11 +717,11 @@ export const CIPPM365OAuthButton = ({ } > {authInProgress || codeRetrievalInProgress - ? "Authenticating..." + ? 'Authenticating...' : deviceCodeInfo && useDeviceCode - ? "Authenticate with Code" + ? 'Authenticate with Code' : buttonText}
- ); -}; + ) +} diff --git a/src/components/CippComponents/CippAddEditTenantGroups.jsx b/src/components/CippComponents/CippAddEditTenantGroups.jsx index 4208c18eaf7c..2ead662a5cd5 100644 --- a/src/components/CippComponents/CippAddEditTenantGroups.jsx +++ b/src/components/CippComponents/CippAddEditTenantGroups.jsx @@ -83,6 +83,12 @@ const CippAddEditTenantGroups = ({ formControl, initialValues, title, backButton compareType="is" compareValue="dynamic" > + a.displayName.localeCompare(b.displayName)); - if (!_.isEqual(selectedApp, newApps)) { + if (!isEqual(selectedApp, newApps)) { setSelectedApp(newApps); // Prevent unnecessary updates } diff --git a/src/components/CippComponents/CippAppTemplateDrawer.jsx b/src/components/CippComponents/CippAppTemplateDrawer.jsx index e9db8701f3d6..4727f66988f2 100644 --- a/src/components/CippComponents/CippAppTemplateDrawer.jsx +++ b/src/components/CippComponents/CippAppTemplateDrawer.jsx @@ -892,6 +892,21 @@ export const CippAppTemplateDrawer = ({ />
+ + + + + {/* Add App Button */} {applicationType?.value && ( diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx index 99c2cd52d249..a68ade232835 100644 --- a/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -1,119 +1,119 @@ -import React, { useEffect, useCallback, useState } from "react"; -import { Divider, Button, Alert, CircularProgress } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm, useWatch } from "react-hook-form"; -import { Add } from "@mui/icons-material"; -import { CippOffCanvas } from "./CippOffCanvas"; -import CippFormComponent from "./CippFormComponent"; -import { CippFormTenantSelector } from "./CippFormTenantSelector"; -import { CippFormCondition } from "./CippFormCondition"; -import { CippApiResults } from "./CippApiResults"; -import languageList from "../../data/languageList.json"; -import { ApiPostCall } from "../../api/ApiCall"; +import React, { useEffect, useCallback, useState } from 'react' +import { Divider, Button, Alert, CircularProgress } from '@mui/material' +import { Grid } from '@mui/system' +import { useForm, useWatch } from 'react-hook-form' +import { Add } from '@mui/icons-material' +import { CippOffCanvas } from './CippOffCanvas' +import CippFormComponent from './CippFormComponent' +import { CippFormTenantSelector } from './CippFormTenantSelector' +import { CippFormCondition } from './CippFormCondition' +import { CippApiResults } from './CippApiResults' +import languageList from '../../data/languageList.json' +import { ApiPostCall } from '../../api/ApiCall' export const CippApplicationDeployDrawer = ({ - buttonText = "Add Application", + buttonText = 'Add Application', requiredPermissions = [], PermissionButton = Button, }) => { - const [drawerVisible, setDrawerVisible] = useState(false); + const [drawerVisible, setDrawerVisible] = useState(false) const formControl = useForm({ - mode: "onChange", - }); + mode: 'onChange', + }) const selectedTenants = useWatch({ control: formControl.control, - name: "selectedTenants", - }); + name: 'selectedTenants', + }) const applicationType = useWatch({ control: formControl.control, - name: "appType", - }); + name: 'appType', + }) const searchQuerySelection = useWatch({ control: formControl.control, - name: "packageSearch", - }); + name: 'packageSearch', + }) const updateSearchSelection = useCallback( (searchQuerySelection) => { if (searchQuerySelection) { - formControl.setValue("packagename", searchQuerySelection.value.packagename); - formControl.setValue("applicationName", searchQuerySelection.value.applicationName); - formControl.setValue("description", searchQuerySelection.value.description); + formControl.setValue('packagename', searchQuerySelection.value.packagename) + formControl.setValue('applicationName', searchQuerySelection.value.applicationName) + formControl.setValue('description', searchQuerySelection.value.description) searchQuerySelection.value.customRepo - ? formControl.setValue("customRepo", searchQuerySelection.value.customRepo) - : null; + ? formControl.setValue('customRepo', searchQuerySelection.value.customRepo) + : null } }, - [formControl.setValue], - ); + [formControl.setValue] + ) useEffect(() => { - updateSearchSelection(searchQuerySelection); - }, [updateSearchSelection, searchQuerySelection]); + updateSearchSelection(searchQuerySelection) + }, [updateSearchSelection, searchQuerySelection]) const postUrl = { - mspApp: "/api/AddMSPApp", - StoreApp: "/api/AddStoreApp", - winGetApp: "/api/AddwinGetApp", - chocolateyApp: "/api/AddChocoApp", - officeApp: "/api/AddOfficeApp", - win32ScriptApp: "/api/AddWin32ScriptApp", - }; + mspApp: '/api/AddMSPApp', + StoreApp: '/api/AddStoreApp', + winGetApp: '/api/AddwinGetApp', + chocolateyApp: '/api/AddChocoApp', + officeApp: '/api/AddOfficeApp', + win32ScriptApp: '/api/AddWin32ScriptApp', + } const ChocosearchResults = ApiPostCall({ urlFromData: true, - }); + }) const winGetSearchResults = ApiPostCall({ urlFromData: true, - }); + }) const deployApplication = ApiPostCall({ urlFromData: true, - relatedQueryKeys: ["Queued Applications"], - }); + relatedQueryKeys: ['Queued Applications'], + }) const searchApp = (searchText, type) => { - if (type === "choco") { + if (type === 'choco') { ChocosearchResults.mutate({ url: `/api/ListAppsRepository`, data: { search: searchText }, queryKey: `SearchApp-${searchText}-${type}`, - }); + }) } - if (type === "StoreApp") { + if (type === 'StoreApp') { winGetSearchResults.mutate({ url: `/api/ListPotentialApps`, - data: { searchString: searchText, type: "WinGet" }, + data: { searchString: searchText, type: 'WinGet' }, queryKey: `SearchApp-${searchText}-${type}`, - }); + }) } - }; + } const handleSubmit = () => { - const formData = formControl.getValues(); - const formattedData = { ...formData }; - formattedData.tenantFilter = "allTenants"; //added to prevent issues with location check. temp fix + const formData = formControl.getValues() + const formattedData = { ...formData } + formattedData.tenantFilter = 'allTenants' //added to prevent issues with location check. temp fix formattedData.selectedTenants = selectedTenants.map((tenant) => ({ defaultDomainName: tenant.value, customerId: tenant.addedFields.customerId, - })); + })) deployApplication.mutate({ url: postUrl[applicationType?.value], data: formattedData, - relatedQueryKeys: ["Queued Applications"], - }); - }; + relatedQueryKeys: ['Queued Applications'], + }) + } const handleCloseDrawer = () => { - setDrawerVisible(false); - formControl.reset(); - }; + setDrawerVisible(false) + formControl.reset() + } return ( <> @@ -130,7 +130,7 @@ export const CippApplicationDeployDrawer = ({ onClose={handleCloseDrawer} size="xl" footer={ -
+
+ + + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- +const CippCAPolicyBuilder = ({ + formControl, + existingPolicy, + disabled = false, + showNamedLocations = false, +}) => { + const policySchema = caSchema; + + // Pre-populate form from existing policy when editing + useEffect(() => { + if (existingPolicy && formControl) { + const populate = (obj, prefix = "") => { + if (!obj || typeof obj !== "object") return; + Object.entries(obj).forEach(([key, value]) => { + // Skip read-only / OData / internal / Graph metadata properties + if ( + (key === "id" && !prefix) || // Only skip top-level policy id + key === "createdDateTime" || + key === "modifiedDateTime" || + key === "deletedDateTime" || + key === "templateId" || + key === "partialEnablementStrategy" || + key.includes("@odata") || // Catch both @odata.type and includePlatforms@odata.type + key.startsWith("#") || // Catch #microsoft.graph.restore etc. + key === "GUID" || + key === "source" || + key === "isSynced" || + key === "package" + ) { + return; + } + // Skip null, empty arrays, and empty strings — treat as "not set" + if (value === null || value === undefined) return; + if (Array.isArray(value) && value.length === 0) return; + if (typeof value === "string" && value.trim() === "") return; + + const path = prefix ? `${prefix}.${key}` : key; + + // Special handling for LocationInfo (template-embedded named locations). + // Graph-shape entries contain `@odata.type` keys that react-hook-form + // would interpret as nested paths, so we map them onto a form-friendly + // shape (`_type`, `_ipRangesText`, …) that NamedLocationsSection consumes. + if (key === "LocationInfo" && Array.isArray(value) && !prefix) { + const formItems = value + .map((item) => namedLocationToForm(item)) + .filter((v) => v !== null); + if (formItems.length > 0) { + formControl.setValue("LocationInfo", formItems); + } + return; + } + + // Special handling for authenticationStrength — only extract the policy ID, + // not the full expanded object (displayName, description, allowedCombinations, etc.) + if (key === "authenticationStrength" && typeof value === "object" && !Array.isArray(value)) { + if (value.id) { + formControl.setValue(`${path}.id`, value.id); + } + return; + } + + // Special handling for guestOrExternalUserTypes — Graph stores as comma-separated + // string but our form uses a multi-select array + if (key === "guestOrExternalUserTypes" && typeof value === "string") { + const types = value.split(",").filter((t) => t.trim() !== "" && t !== "none"); + if (types.length > 0) { + formControl.setValue(path, types); + } + return; + } + + // Special handling for externalTenants — extract members and set _scope + if (key === "externalTenants" && typeof value === "object" && !Array.isArray(value)) { + if (value.members && Array.isArray(value.members) && value.members.length > 0) { + formControl.setValue(`${path}.members`, value.members); + formControl.setValue(`${path}._scope`, { label: "Specific tenants", value: "enumerated" }); + } else { + formControl.setValue(`${path}._scope`, { label: "All external tenants", value: "all" }); + } + return; + } + + if (typeof value === "object" && !Array.isArray(value)) { + populate(value, path); + } else { + formControl.setValue(path, value); + } + }); + }; + populate(existingPolicy); + } + }, [existingPolicy, formControl]); + + // Schema-level validation: extract options for top-level policy state + const stateOpts = useMemo( + () => enumToOptions(policySchema.properties.state), + [policySchema] + ); + + return ( + + {/* Policy basics */} + + + + + + + + + + + + + {/* Users & Groups */} + + }> + + Users and Groups + + + + + + + + {/* Cloud Apps or Actions */} + + }> + + Cloud Apps or Actions + + + + + + + + {/* Conditions */} + + }> + + Conditions + + + + + + + + {/* Grant Controls */} + + }> + + Grant Controls + + + + + + + + {/* Session Controls */} + + }> + + Session Controls + + + + + + + + {/* Named Locations (template only) */} + {showNamedLocations && ( + + }> + + + Named Locations + + + + + + + + + )} + + ); +}; + +export default CippCAPolicyBuilder; + +/** + * Utility: extract a clean CA policy JSON from react-hook-form values. + * + * Call this in your form's submit handler to strip out { label, value } + * wrapper objects from autoComplete fields, remove empty/null branches, + * and ensure the JSON is ready to send to AddCAPolicy / AddCATemplate. + */ +export function extractCAPolicyJSON(formValues) { + const clean = (obj) => { + if (obj === null || obj === undefined) return undefined; + + // Unwrap {label,value} from autoComplete + if (typeof obj === "object" && "value" in obj && "label" in obj) { + return obj.value; + } + + if (Array.isArray(obj)) { + const arr = obj.map(clean).filter((v) => v !== undefined && v !== null && v !== ""); + return arr.length > 0 ? arr : undefined; + } + + if (typeof obj === "object") { + const result = {}; + let hasContent = false; + for (const [key, value] of Object.entries(obj)) { + // Strip internal builder fields (e.g. _scope) + if (key.startsWith("_")) continue; + // Strip OData annotations EXCEPT @odata.type (required by Graph for polymorphic types) + if (key === "@odata.type") { + result[key] = value; + hasContent = true; + continue; + } + if (key.includes("@odata") || key.startsWith("#")) continue; + + const cleaned = clean(value); + if (cleaned !== undefined) { + result[key] = cleaned; + hasContent = true; + } + } + return hasContent ? result : undefined; + } + + // Booleans, numbers, non-empty strings pass through + if (typeof obj === "string" && obj.trim() === "") return undefined; + return obj; + }; + + const cleaned = clean(formValues) ?? {}; + + // Post-process: fix guestsOrExternalUsers structures for Graph API + const fixGuestExternalUsers = (guestObj) => { + if (!guestObj) return guestObj; + // Graph expects guestOrExternalUserTypes as a comma-separated string + if (Array.isArray(guestObj.guestOrExternalUserTypes)) { + guestObj.guestOrExternalUserTypes = guestObj.guestOrExternalUserTypes.join(","); + } + // Determine scope from the internal _scope field or from members presence + const scope = guestObj.externalTenants?._scope; + const hasMembers = + guestObj.externalTenants?.members && guestObj.externalTenants.members.length > 0; + + if (guestObj.externalTenants) { + // Remove internal _scope field + delete guestObj.externalTenants._scope; + + if (scope === "enumerated" || hasMembers) { + guestObj.externalTenants["@odata.type"] = + "#microsoft.graph.conditionalAccessEnumeratedExternalTenants"; + guestObj.externalTenants.membershipKind = "enumerated"; + } else { + guestObj.externalTenants = { + "@odata.type": "#microsoft.graph.conditionalAccessAllExternalTenants", + membershipKind: "all", + }; + } + } else if (guestObj.guestOrExternalUserTypes) { + // No tenants specified — default to all external tenants + guestObj.externalTenants = { + "@odata.type": "#microsoft.graph.conditionalAccessAllExternalTenants", + membershipKind: "all", + }; + } + return guestObj; + }; + + if (cleaned.conditions?.users?.excludeGuestsOrExternalUsers) { + cleaned.conditions.users.excludeGuestsOrExternalUsers = fixGuestExternalUsers( + cleaned.conditions.users.excludeGuestsOrExternalUsers + ); + } + if (cleaned.conditions?.users?.includeGuestsOrExternalUsers) { + cleaned.conditions.users.includeGuestsOrExternalUsers = fixGuestExternalUsers( + cleaned.conditions.users.includeGuestsOrExternalUsers + ); + } + + // Post-process: strip session control sub-objects where isEnabled is false. + // Graph validates fields like `mode` even when disabled — safest to omit entirely. + if (cleaned.sessionControls) { + const sessionKeys = [ + "applicationEnforcedRestrictions", + "cloudAppSecurity", + "signInFrequency", + "persistentBrowser", + ]; + for (const key of sessionKeys) { + if (cleaned.sessionControls[key]?.isEnabled === false) { + delete cleaned.sessionControls[key]; + } + } + // If sessionControls is now empty, remove it too + if (Object.keys(cleaned.sessionControls).length === 0) { + delete cleaned.sessionControls; + } + } + + // Post-process: convert template-embedded named locations from form-shape + // back to Graph shape. We read from the raw form values (not `cleaned`) + // because `clean()` strips internal keys prefixed with `_` (e.g. `_type`, + // `_ipRangesText`) that the conversion needs. + delete cleaned.LocationInfo; + if (Array.isArray(formValues?.LocationInfo)) { + const graphLocations = formValues.LocationInfo + .map((item) => namedLocationToGraph(item)) + .filter((v) => v !== null); + if (graphLocations.length > 0) { + cleaned.LocationInfo = graphLocations; + } + } + + return cleaned; +} diff --git a/src/components/CippComponents/CippCustomVariables.jsx b/src/components/CippComponents/CippCustomVariables.jsx index 69b5975d1777..d63f2a7f81a6 100644 --- a/src/components/CippComponents/CippCustomVariables.jsx +++ b/src/components/CippComponents/CippCustomVariables.jsx @@ -1,57 +1,57 @@ -import { useState } from "react"; -import { CardContent, Button, SvgIcon, Alert } from "@mui/material"; -import { PlusIcon, TrashIcon, PencilIcon } from "@heroicons/react/24/outline"; -import { CippDataTable } from "../CippTable/CippDataTable"; -import { CippApiResults } from "./CippApiResults"; -import { CippApiDialog } from "./CippApiDialog"; -import { ApiPostCall } from "../../api/ApiCall"; +import { useState } from 'react' +import { CardContent, Button, SvgIcon, Alert } from '@mui/material' +import { PlusIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline' +import { CippDataTable } from '../CippTable/CippDataTable' +import { CippApiResults } from './CippApiResults' +import { CippApiDialog } from './CippApiDialog' +import { ApiPostCall } from '../../api/ApiCall' const CippCustomVariables = ({ id }) => { - const [openAddDialog, setOpenAddDialog] = useState(false); + const [openAddDialog, setOpenAddDialog] = useState(false) // Simple cache invalidation using React Query wildcard support - const allRelatedKeys = ["CustomVariables*"]; + const allRelatedKeys = ['CustomVariables*'] const updateCustomVariablesApi = ApiPostCall({ urlFromData: true, relatedQueryKeys: allRelatedKeys, - }); + }) const reservedVariables = [ - "tenantid", - "tenantname", - "tenantfilter", - "partnertenantid", - "samappid", - "cippuserschema", - "cippurl", - "defaultdomain", - "serial", - "systemroot", - "systemdrive", - "temp", - "userprofile", - "username", - "userdomain", - "windir", - "programfiles", - "programfiles(x86)", - "programdata", - ]; + 'tenantid', + 'tenantname', + 'tenantfilter', + 'partnertenantid', + 'samappid', + 'cippuserschema', + 'cippurl', + 'defaultdomain', + 'serial', + 'systemroot', + 'systemdrive', + 'temp', + 'userprofile', + 'username', + 'userdomain', + 'windir', + 'programfiles', + 'programfiles(x86)', + 'programdata', + ] const validateVariableName = (value) => { if (reservedVariables.includes(value.toLowerCase())) { - return "The variable name is reserved and cannot be used."; - } else if (!value.includes(" ") && !/[~`!#$%\^&*+=\-\[\]\\';,/{}|\\":<>\?]/g.test(value)) { - return true; + return 'The variable name is reserved and cannot be used.' + } else if (!value.includes(' ') && !/[~`!#$%\^&*+=\-\[\]\\';,/{}|\\":<>\?]/g.test(value)) { + return true } else { - return "The variable name must not contain spaces or special characters."; + return 'The variable name must not contain spaces or special characters.' } - }; + } const actions = [ { - label: "Edit", + label: 'Edit', icon: ( @@ -60,77 +60,85 @@ const CippCustomVariables = ({ id }) => { confirmText: "Update the custom variable '[RowKey]'?", hideBulk: true, setDefaultValues: true, + condition: (row) => row.Scope !== 'Global' || id === 'AllTenants', fields: [ { - type: "textField", - name: "RowKey", - label: "Variable Name", - placeholder: "Enter the key for the custom variable.", + type: 'textField', + name: 'RowKey', + label: 'Variable Name', + placeholder: 'Enter the key for the custom variable.', required: true, disableVariables: true, validators: { validate: validateVariableName }, }, { - type: "textField", - name: "Value", - label: "Value", - disableVariables: true, - placeholder: "Enter the value for the custom variable.", + type: 'textField', + name: 'Value', + label: 'Value', + placeholder: 'Enter the value for the custom variable.', required: true, }, { - type: "textField", - name: "Description", - label: "Description", - placeholder: "Enter a description for the custom variable.", + type: 'textField', + name: 'Description', + label: 'Description', + placeholder: 'Enter a description for the custom variable.', required: false, disableVariables: true, }, ], - type: "POST", - url: "/api/ExecCippReplacemap", + type: 'POST', + url: '/api/ExecCippReplacemap', data: { - Action: "!AddEdit", + Action: '!AddEdit', tenantId: id, }, relatedQueryKeys: allRelatedKeys, }, { - label: "Delete", + label: 'Delete', icon: , - confirmText: "Are you sure you want to delete [RowKey]?", - type: "POST", - url: "/api/ExecCippReplacemap", + confirmText: 'Are you sure you want to delete [RowKey]?', + condition: (row) => row.Scope !== 'Global' || id === 'AllTenants', + type: 'POST', + url: '/api/ExecCippReplacemap', data: { - Action: "Delete", - RowKey: "RowKey", + Action: 'Delete', + RowKey: 'RowKey', tenantId: id, }, relatedQueryKeys: allRelatedKeys, multiPost: false, }, - ]; + ] const handleAddVariable = () => { - setOpenAddDialog(true); - }; + setOpenAddDialog(true) + } return ( - {id === "AllTenants" + {id === 'AllTenants' ? "Global variables are key-value pairs that can be used to store additional information for All Tenants. These are applied to templates in standards using the format %variablename%. If a tenant has a custom variable with the same name, the tenant's variable will take precedence." - : "Custom variables are key-value pairs that can be used to store additional information about a tenant. These are applied to templates in standards using the format %variablename%."} + : 'Custom variables are key-value pairs that can be used to store additional information about a tenant. These are applied to templates in standards using the format %variablename%.'} { title="Add Variable" fields={[ { - type: "textField", - name: "RowKey", - label: "Variable Name", - placeholder: "Enter the name for the custom variable without %.", + type: 'textField', + name: 'RowKey', + label: 'Variable Name', + placeholder: 'Enter the name for the custom variable without %.', required: true, disableVariables: true, validators: { validate: validateVariableName }, }, { - type: "textField", - name: "Value", - label: "Value", - disableVariables: true, - placeholder: "Enter the value for the custom variable.", + type: 'textField', + name: 'Value', + label: 'Value', + placeholder: 'Enter the value for the custom variable.', required: true, }, { - type: "textField", - name: "Description", - label: "Description", - placeholder: "Enter a description for the custom variable.", + type: 'textField', + name: 'Description', + label: 'Description', + placeholder: 'Enter a description for the custom variable.', required: false, disableVariables: true, }, ]} api={{ - type: "POST", - url: "/api/ExecCippReplacemap", - data: { Action: "AddEdit", tenantId: id }, + type: 'POST', + url: '/api/ExecCippReplacemap', + data: { Action: 'AddEdit', tenantId: id }, relatedQueryKeys: allRelatedKeys, }} /> - ); -}; + ) +} -export default CippCustomVariables; +export default CippCustomVariables diff --git a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx index 3cf35e3cb2f8..c03c976f9f94 100644 --- a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx +++ b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx @@ -88,18 +88,21 @@ const MODE_CONFIG = { listTemplatesUrl: "/api/ListSensitiveInfoTypeTemplates", templateQueryKey: "TemplateListSensitiveInfoType", relatedQueryKeys: ["ListSensitiveInfoType", "ListSensitiveInfoTypeTemplates"], - placeholder: `{ - "Name": "Custom Employee ID", - "Description": "Internal Employee ID format EMP-NNNNN", + placeholder: `// Simple mode — backend wraps the regex in a rule pack for you +{ + "Name": "Acme Employee ID", + "Description": "Matches Acme employee IDs in the format EMP-NNNNN", "Pattern": "EMP-\\\\d{5}", - "Confidence": "High", - "Recommended": true + "Confidence": 85, + "PatternsProximity": 300, + "PublisherName": "Acme Corp" } -// Or with a base64-encoded XML rule pack: +// Advanced mode — provide your own rule pack XML, base64-encoded // { -// "Name": "Custom Rule Pack", -// "FileDataBase64": "" +// "Name": "Acme Custom Rule Pack", +// "Description": "Multi-pattern rule pack", +// "FileDataBase64": "" // }`, }, }; diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 638d67b50e81..049337ccaf25 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -12,6 +12,7 @@ import { Box, Input, Tooltip, + Alert, } from "@mui/material"; import { CippAutoComplete } from "./CippAutocomplete"; import { CippTextFieldWithVariables } from "./CippTextFieldWithVariables"; @@ -94,6 +95,13 @@ export const CippFormComponent = (props) => { ); + case "alert": + return ( + + {label} + + ); + case "hidden": return ( { - const userSettingsDefaults = useSettings(); + const userSettingsDefaults = useSettings() return ( `${getCippLicenseTranslation([option])} (${option?.availableUnits} available)`, - valueField: "skuId", + valueField: 'skuId', queryKey: `ListLicenses-${userSettingsDefaults?.currentTenant ?? undefined}`, data: { - Endpoint: "subscribedSkus", + Endpoint: 'subscribedSkus', $count: true, }, showRefresh, }} /> - ); -}; + ) +} diff --git a/src/components/CippComponents/CippFormTemplateTenantSelector.jsx b/src/components/CippComponents/CippFormTemplateTenantSelector.jsx new file mode 100644 index 000000000000..4ee436723abf --- /dev/null +++ b/src/components/CippComponents/CippFormTemplateTenantSelector.jsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react' +import { CippFormComponent } from './CippFormComponent' +import { ApiGetCall } from '../../api/ApiCall' + +/** + * A tenant selector scoped to the tenants applicable to a given standards template. + * + * - If the template targets AllTenants, all tenants are offered plus an "All Tenants" option. + * - If the template targets tenant groups, each group's members are fetched and offered, + * plus the group itself as a "run for whole group" option. + * - Individual tenant entries are offered directly. + * + * The final value sent to the API will be the tenant's defaultDomainName, a group ID, or + * "allTenants". + */ +export const CippFormTemplateTenantSelector = ({ + formControl, + templateTenants = [], + excludedTenants = [], + name = 'tenantFilter', + label = 'Select Tenant', + placeholder = 'Select a tenant, group, or All Tenants...', + required = true, + ...other +}) => { + // Build a set of excluded values for fast lookup + const excludedValues = new Set( + excludedTenants.map((t) => (typeof t === 'object' ? t?.value : t)).filter(Boolean) + ) + const isExcluded = (value) => excludedValues.has(value) + const [options, setOptions] = useState([]) + + // Determine what the template targets + const hasAllTenants = templateTenants.some( + (t) => t?.value === 'AllTenants' || t?.value === 'allTenants' + ) + const groupIds = templateTenants.filter((t) => t?.type === 'Group').map((t) => t.value) + const individualTenants = templateTenants.filter( + (t) => t?.type !== 'Group' && t?.value !== 'AllTenants' && t?.value !== 'allTenants' + ) + + // Fetch all tenants when AllTenants is targeted + const allTenantList = ApiGetCall({ + url: '/api/ListTenants?AllTenantSelector=true', + queryKey: 'ListTenants-TemplateTenantSelector', + waiting: hasAllTenants, + }) + + // Fetch each group's members (one request per group) + const groupRequests = groupIds.map((id) => + // eslint-disable-next-line react-hooks/rules-of-hooks + ApiGetCall({ + url: `/api/ListTenantGroups?groupId=${id}`, + queryKey: `TenantGroup-${id}`, + waiting: groupIds.length > 0, + }) + ) + + useEffect(() => { + const built = [{ label: 'All Tenants in Template', value: 'allTenants', group: 'All Tenants' }] + + if (hasAllTenants) { + if (allTenantList.isSuccess && Array.isArray(allTenantList.data)) { + allTenantList.data.forEach((tenant) => { + if (isExcluded(tenant.defaultDomainName)) return + built.push({ + label: `${tenant.displayName} (${tenant.defaultDomainName})`, + value: tenant.defaultDomainName, + group: 'Individual Tenants', + }) + }) + } + } + + groupRequests.forEach((req, idx) => { + const groupId = groupIds[idx] + const groupEntry = templateTenants.find((t) => t.value === groupId) + const groupName = groupEntry?.label ?? groupId + + if (req.isSuccess) { + const results = Array.isArray(req.data?.Results) + ? req.data.Results + : Array.isArray(req.data) + ? req.data + : [] + const matchedGroup = results.find((g) => g.Id === groupId) ?? results[0] + + if (matchedGroup) { + // Individual members only — group itself is not selectable + const members = Array.isArray(matchedGroup.Members) ? matchedGroup.Members : [] + members.forEach((m) => { + if (isExcluded(m.defaultDomainName)) return + built.push({ + label: `${m.displayName ?? m.defaultDomainName} (${m.defaultDomainName})`, + value: m.defaultDomainName, + group: matchedGroup.Name ?? groupName, + }) + }) + } + } + }) + + // Individual tenant entries from the template + individualTenants.forEach((t) => { + if (isExcluded(t.value)) return + if (!built.some((b) => b.value === t.value)) { + built.push({ + label: t.label ?? t.value, + value: t.value, + group: 'Individual Tenants', + }) + } + }) + + setOptions(built) + }, [ + hasAllTenants, + allTenantList.isSuccess, + allTenantList.data, + ...groupRequests.map((r) => r.isSuccess), + ...groupRequests.map((r) => r.data), + ]) + + const isFetching = + (hasAllTenants && allTenantList.isFetching) || groupRequests.some((r) => r.isFetching) + + return ( + option.group} + validators={ + required ? { required: { value: true, message: 'Please select a tenant' } } : undefined + } + {...other} + /> + ) +} diff --git a/src/components/CippComponents/CippHVEUserDrawer.jsx b/src/components/CippComponents/CippHVEUserDrawer.jsx index 3c4ba53ca852..56505c0f18dd 100644 --- a/src/components/CippComponents/CippHVEUserDrawer.jsx +++ b/src/components/CippComponents/CippHVEUserDrawer.jsx @@ -98,7 +98,8 @@ export const CippHVEUserDrawer = ({ HVE SMTP Configuration Settings:
  • - Server: smtp-hve.office365.com + Server: smtp.hve.mx.microsoft (recommended) or + smtp-hve.office365.com (deprecated)
  • Port: 587 @@ -109,9 +110,12 @@ export const CippHVEUserDrawer = ({
  • TLS Support: TLS 1.2 and TLS 1.3
  • +
  • + Authentication: HVE account credentials or OAuth token +
  • - Use these settings to configure your email client for HVE access. + Use these settings to configure your application or device for HVE access. diff --git a/src/components/CippComponents/CippIntunePolicyActions.jsx b/src/components/CippComponents/CippIntunePolicyActions.jsx index 531245e94625..09faf1efebf5 100644 --- a/src/components/CippComponents/CippIntunePolicyActions.jsx +++ b/src/components/CippComponents/CippIntunePolicyActions.jsx @@ -1,15 +1,15 @@ -import { Book, LaptopChromebook } from "@mui/icons-material"; -import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from "@heroicons/react/24/outline"; +import { Book, LaptopChromebook } from '@mui/icons-material' +import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from '@heroicons/react/24/outline' const assignmentModeOptions = [ - { label: "Replace existing assignments", value: "replace" }, - { label: "Append to existing assignments", value: "append" }, -]; + { label: 'Replace existing assignments', value: 'replace' }, + { label: 'Append to existing assignments', value: 'append' }, +] const assignmentFilterTypeOptions = [ - { label: "Include - Apply policy to devices matching filter", value: "include" }, - { label: "Exclude - Apply policy to devices NOT matching filter", value: "exclude" }, -]; + { label: 'Include - Apply policy to devices matching filter', value: 'include' }, + { label: 'Exclude - Apply policy to devices NOT matching filter', value: 'exclude' }, +] /** * Get assignment actions for Intune policies @@ -30,184 +30,191 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => includeDelete = true, deleteUrlName = policyType, templateData = null, - } = options; + } = options const getAssignmentFields = () => [ { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", + type: 'radio', + name: 'assignmentMode', + label: 'Assignment mode', options: assignmentModeOptions, - defaultValue: "replace", + defaultValue: 'replace', helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups.", + 'Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups.', }, { - type: "autoComplete", - name: "assignmentFilter", - label: "Assignment Filter (Optional)", + type: 'autoComplete', + name: 'assignmentFilter', + label: 'Assignment Filter (Optional)', multiple: false, creatable: false, api: { - url: "/api/ListAssignmentFilters", + url: '/api/ListAssignmentFilters', queryKey: `ListAssignmentFilters-${tenant}`, labelField: (filter) => filter.displayName, - valueField: "displayName", + valueField: 'displayName', }, }, { - type: "radio", - name: "assignmentFilterType", - label: "Assignment Filter Mode", + type: 'radio', + name: 'assignmentFilterType', + label: 'Assignment Filter Mode', options: assignmentFilterTypeOptions, - defaultValue: "include", - helperText: "Choose whether to include or exclude devices matching the filter.", + defaultValue: 'include', + helperText: 'Choose whether to include or exclude devices matching the filter.', }, - ]; + { + type: 'textField', + name: 'excludeGroup', + label: 'Exclude Group Names separated by comma. Wildcards (*) are allowed', + }, + ] const getCustomDataFormatter = (assignTo) => (row, action, formData) => { - const rows = Array.isArray(row) ? row : [row]; + const rows = Array.isArray(row) ? row : [row] return rows.map((item) => ({ - tenantFilter: tenant === "AllTenants" && item?.Tenant ? item.Tenant : tenant, + tenantFilter: tenant === 'AllTenants' && item?.Tenant ? item.Tenant : tenant, ID: item?.id, type: item?.URLName || policyType, ...(platformType && { platformType }), AssignTo: assignTo, - assignmentMode: formData?.assignmentMode || "replace", + assignmentMode: formData?.assignmentMode || 'replace', + excludeGroup: formData?.excludeGroup || null, AssignmentFilterName: formData?.assignmentFilter?.value || null, AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" + ? formData?.assignmentFilterType || 'include' : null, - })); - }; + })) + } const getCustomDataFormatterForGroups = () => (row, action, formData) => { - const rows = Array.isArray(row) ? row : [row]; - const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; + const rows = Array.isArray(row) ? row : [row] + const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : [] return rows.map((item) => ({ - tenantFilter: tenant === "AllTenants" && item?.Tenant ? item.Tenant : tenant, + tenantFilter: tenant === 'AllTenants' && item?.Tenant ? item.Tenant : tenant, ID: item?.id, type: item?.URLName || policyType, ...(platformType && { platformType }), GroupIds: selectedGroups.map((group) => group.value).filter(Boolean), GroupNames: selectedGroups.map((group) => group.label).filter(Boolean), - assignmentMode: formData?.assignmentMode || "replace", + assignmentMode: formData?.assignmentMode || 'replace', + excludeGroup: formData?.excludeGroup || null, AssignmentFilterName: formData?.assignmentFilter?.value || null, AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" + ? formData?.assignmentFilterType || 'include' : null, - })); - }; + })) + } - const actions = []; + const actions = [] // Create template action if (includeCreateTemplate) { actions.push({ - label: "Create template based on policy", - type: "POST", - url: "/api/AddIntuneTemplate", + label: 'Create template based on policy', + type: 'POST', + url: '/api/AddIntuneTemplate', data: templateData || { - ID: "id", - URLName: policyType === "URLName" ? "URLName" : policyType, + ID: 'id', + URLName: policyType === 'URLName' ? 'URLName' : policyType, }, - confirmText: "Are you sure you want to create a template based on this policy?", + confirmText: 'Are you sure you want to create a template based on this policy?', icon: , - color: "info", + color: 'info', multiPost: false, - }); + }) } // Assign to All Users actions.push({ - label: "Assign to All Users", - type: "POST", - url: "/api/ExecAssignPolicy", + label: 'Assign to All Users', + type: 'POST', + url: '/api/ExecAssignPolicy', data: { - AssignTo: "allLicensedUsers", - ID: "id", - type: policyType === "URLName" ? "URLName" : policyType, - ...(platformType && { platformType: "!deviceAppManagement" }), + AssignTo: 'allLicensedUsers', + ID: 'id', + type: policyType === 'URLName' ? 'URLName' : policyType, + ...(platformType && { platformType: '!deviceAppManagement' }), }, multiPost: false, fields: getAssignmentFields(), - customDataformatter: getCustomDataFormatter("allLicensedUsers"), + customDataformatter: getCustomDataFormatter('allLicensedUsers'), confirmText: 'Are you sure you want to assign "[displayName]" to all users?', icon: , - color: "info", - }); + color: 'info', + }) // Assign to All Devices actions.push({ - label: "Assign to All Devices", - type: "POST", - url: "/api/ExecAssignPolicy", + label: 'Assign to All Devices', + type: 'POST', + url: '/api/ExecAssignPolicy', data: { - AssignTo: "AllDevices", - ID: "id", - type: policyType === "URLName" ? "URLName" : policyType, - ...(platformType && { platformType: "!deviceAppManagement" }), + AssignTo: 'AllDevices', + ID: 'id', + type: policyType === 'URLName' ? 'URLName' : policyType, + ...(platformType && { platformType: '!deviceAppManagement' }), }, multiPost: false, fields: getAssignmentFields(), - customDataformatter: getCustomDataFormatter("AllDevices"), + customDataformatter: getCustomDataFormatter('AllDevices'), confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', icon: , - color: "info", - }); + color: 'info', + }) // Assign Globally (All Users / All Devices) actions.push({ - label: "Assign Globally (All Users / All Devices)", - type: "POST", - url: "/api/ExecAssignPolicy", + label: 'Assign Globally (All Users / All Devices)', + type: 'POST', + url: '/api/ExecAssignPolicy', data: { - AssignTo: "AllDevicesAndUsers", - ID: "id", - type: policyType === "URLName" ? "URLName" : policyType, - ...(platformType && { platformType: "!deviceAppManagement" }), + AssignTo: 'AllDevicesAndUsers', + ID: 'id', + type: policyType === 'URLName' ? 'URLName' : policyType, + ...(platformType && { platformType: '!deviceAppManagement' }), }, multiPost: false, fields: getAssignmentFields(), - customDataformatter: getCustomDataFormatter("AllDevicesAndUsers"), + customDataformatter: getCustomDataFormatter('AllDevicesAndUsers'), confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', icon: , - color: "info", - }); + color: 'info', + }) // Assign to Custom Group actions.push({ - label: "Assign to Custom Group", - type: "POST", - url: "/api/ExecAssignPolicy", + label: 'Assign to Custom Group', + type: 'POST', + url: '/api/ExecAssignPolicy', icon: , - color: "info", + color: 'info', confirmText: 'Select the target groups for "[displayName]".', multiPost: false, fields: [ { - type: "autoComplete", - name: "groupTargets", - label: "Group(s)", + type: 'autoComplete', + name: 'groupTargets', + label: 'Group(s)', multiple: true, creatable: false, allowResubmit: true, - validators: { required: "Please select at least one group" }, + validators: { required: 'Please select at least one group' }, api: { - url: "/api/ListGraphRequest", - dataKey: "Results", + url: '/api/ListGraphRequest', + dataKey: 'Results', queryKey: `ListPolicyAssignmentGroups-${tenant}`, labelField: (group) => group.id ? `${group.displayName} (${group.id})` : group.displayName, - valueField: "id", + valueField: 'id', addedField: { - description: "description", + description: 'description', }, data: { - Endpoint: "groups", + Endpoint: 'groups', manualPagination: true, - $select: "id,displayName,description", - $orderby: "displayName", + $select: 'id,displayName,description', + $orderby: 'displayName', $top: 999, $count: true, }, @@ -216,23 +223,23 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => ...getAssignmentFields(), ], customDataformatter: getCustomDataFormatterForGroups(), - }); + }) // Delete action if (includeDelete) { actions.push({ - label: "Delete Policy", - type: "POST", - url: "/api/RemovePolicy", + label: 'Delete Policy', + type: 'POST', + url: '/api/RemovePolicy', data: { - ID: "id", - URLName: deleteUrlName === "URLName" ? "URLName" : deleteUrlName, + ID: 'id', + URLName: deleteUrlName === 'URLName' ? 'URLName' : deleteUrlName, }, - confirmText: "Are you sure you want to delete this policy?", + confirmText: 'Are you sure you want to delete this policy?', icon: , - color: "danger", - }); + color: 'danger', + }) } - return actions; -}; + return actions +} diff --git a/src/components/CippComponents/CippLicenseDetailsDrawer.jsx b/src/components/CippComponents/CippLicenseDetailsDrawer.jsx new file mode 100644 index 000000000000..02e69948199a --- /dev/null +++ b/src/components/CippComponents/CippLicenseDetailsDrawer.jsx @@ -0,0 +1,196 @@ +import { + Box, + CircularProgress, + Divider, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { CippCopyToClipBoard } from "./CippCopyToClipboard"; +import { ApiGetCall } from "../../api/ApiCall"; + +const Field = ({ label, value, mono = false }) => { + if (value === null || value === undefined || value === "") return null; + return ( + + + + {label} + + + + {String(value)} + + + + ); +}; + +export const CippLicenseDetailsDrawer = ({ data }) => { + if (!data) return null; + const fromCatalog = data.source === "catalog"; + + // For catalog-only hits, backfill tenant usage from the API by skuId. + const usageQuery = ApiGetCall({ + url: `/api/ExecUniversalSearchV2`, + data: { + searchTerms: data.skuId || data.skuPartNumber || "", + limit: 1, + type: "Licenses", + }, + queryKey: `licenseUsage-${data.skuId || data.skuPartNumber || ""}`, + waiting: fromCatalog && Boolean(data.skuId || data.skuPartNumber), + }); + + const apiMatch = (() => { + if (!fromCatalog || !usageQuery.isSuccess) return null; + const rows = Array.isArray(usageQuery.data) ? usageQuery.data : []; + const target = String(data.skuId || "").toLowerCase(); + const hit = + rows.find((r) => String(r?.Data?.skuId || "").toLowerCase() === target) || rows[0]; + return hit?.Data || null; + })(); + + const merged = apiMatch + ? { + ...data, + tenantCount: apiMatch.tenantCount ?? data.tenantCount, + totalAssigned: apiMatch.totalAssigned ?? data.totalAssigned, + totalAvailable: apiMatch.totalAvailable ?? data.totalAvailable, + tenants: Array.isArray(apiMatch.tenants) ? apiMatch.tenants : [], + } + : data; + + const servicePlans = Array.isArray(merged.servicePlans) ? merged.servicePlans : []; + const tenants = Array.isArray(merged.tenants) ? merged.tenants : []; + const hasUsage = !fromCatalog || apiMatch !== null; + const usageLoading = fromCatalog && usageQuery.isFetching && !apiMatch; + const usageNotFound = fromCatalog && usageQuery.isSuccess && !apiMatch; + + return ( + + + + {merged.displayName || merged.skuPartNumber || "License"} + + + {hasUsage + ? `${merged.tenantCount || 0} tenant${merged.tenantCount === 1 ? "" : "s"} · ${ + merged.totalAssigned || 0 + }/${merged.totalAvailable || 0} assigned` + : usageLoading + ? "Microsoft published catalog · loading tenant usage…" + : usageNotFound + ? "Microsoft published catalog · not assigned in any tenant" + : "Microsoft published catalog"} + + + + + + + + {hasUsage && ( + <> + + + + + )} + + {usageLoading && ( + + + + Looking up tenant usage… + + + )} + + {servicePlans.length > 0 && ( + <> + + Service plans ({servicePlans.length}) + + + + + + Name + Friendly name + Service plan ID + + + + + {servicePlans.map((plan, idx) => { + const id = plan.servicePlanId || plan.servicePlanid || ""; + const name = plan.servicePlanName || ""; + const friendly = plan.friendlyName || ""; + return ( + + {name} + {friendly} + {id} + {id && } + + ); + })} + +
    +
    + + )} + + {tenants.length > 0 && ( + <> + + + Tenants ({tenants.length}) + + + + + + Tenant + Used + Total + + + + + {tenants.map((t, idx) => ( + + {t.tenant} + {t.used ?? "-"} + {t.total ?? "-"} + + {t.tenant && } + + + ))} + +
    +
    + + )} +
    + ); +}; diff --git a/src/components/CippComponents/CippNotificationForm.jsx b/src/components/CippComponents/CippNotificationForm.jsx index 03ecce096ec9..f2f74b19d7f1 100644 --- a/src/components/CippComponents/CippNotificationForm.jsx +++ b/src/components/CippComponents/CippNotificationForm.jsx @@ -42,6 +42,7 @@ export const CippNotificationForm = ({ { label: "Adding a group", value: "AddGroup" }, { label: "Adding a tenant", value: "NewTenant" }, { label: "Executing the offboard wizard", value: "ExecOffboardUser" }, + { label: "Custom Test Alerts", value: "CustomTests" }, ]; const severityTypes = [ diff --git a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx index 7ad68f3b74cb..5b6189ad2a7c 100644 --- a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx +++ b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx @@ -1,41 +1,41 @@ -import { CippPropertyListCard } from "../../components/CippCards/CippPropertyListCard"; -import CippFormComponent from "../../components/CippComponents/CippFormComponent"; -import { Typography, Box } from "@mui/material"; +import { CippPropertyListCard } from '../../components/CippCards/CippPropertyListCard' +import CippFormComponent from '../../components/CippComponents/CippFormComponent' +import { Typography, Box } from '@mui/material' export const CippOffboardingDefaultSettings = (props) => { - const { formControl, defaultsSource = null, title = "Offboarding Default Settings" } = props; - + const { formControl, defaultsSource = null, title = 'Offboarding Default Settings' } = props + const getSourceIndicator = () => { // Only show the indicator if defaultsSource is explicitly provided (for wizard, not tenant config) - if (!defaultsSource || defaultsSource === null) return null; - - let sourceText = ""; - let color = "text.secondary"; - + if (!defaultsSource || defaultsSource === null) return null + + let sourceText = '' + let color = 'text.secondary' + switch (defaultsSource) { - case "tenant": - sourceText = "Using Tenant Defaults"; - color = "primary.main"; - break; - case "user": - sourceText = "Using User Defaults"; - color = "info.main"; - break; - case "none": + case 'tenant': + sourceText = 'Using Tenant Defaults' + color = 'primary.main' + break + case 'user': + sourceText = 'Using User Defaults' + color = 'info.main' + break + case 'none': default: - sourceText = "Using Default Settings"; - color = "text.secondary"; - break; + sourceText = 'Using Default Settings' + color = 'text.secondary' + break } - + return ( - + {sourceText} - ); - }; + ) + } return ( <> @@ -45,178 +45,188 @@ export const CippOffboardingDefaultSettings = (props) => { showDivider={false} title={title} propertyItems={[ - { - label: "Convert to Shared Mailbox", - value: ( - - ), - }, - { - label: "Remove from all groups", - value: ( - - ), - }, - { - label: "Hide from Global Address List", - value: ( - - ), - }, - { - label: "Remove Licenses", - value: ( - - ), - }, - { - label: "Cancel all calendar invites", - value: ( - - ), - }, - { - label: "Revoke all sessions", - value: ( - - ), - }, - { - label: "Remove users mailbox permissions", - value: ( - - ), - }, - { - label: "Remove users calendar permissions", - value: ( - - ), - }, - { - label: "Remove all Rules", - value: ( - - ), - }, - { - label: "Reset Password", - value: ( - - ), - }, - { - label: "Keep copy of forwarded mail in source mailbox", - value: ( - - ), - }, - { - label: "Delete user", - value: ( - - ), - }, - { - label: "Remove all Mobile Devices", - value: ( - - ), - }, - { - label: "Disable Sign in", - value: ( - - ), - }, - { - label: "Remove all MFA Devices", - value: ( - - ), - }, - { - label: "Remove Teams Phone DID", - value: ( - - ), - }, - { - label: "Clear Immutable ID", - value: ( - - ), - }, - ]} - /> + { + label: 'Convert to Shared Mailbox', + value: ( + + ), + }, + { + label: 'Remove from all groups', + value: ( + + ), + }, + { + label: 'Hide from Global Address List', + value: ( + + ), + }, + { + label: 'Remove Licenses', + value: ( + + ), + }, + { + label: 'Cancel all calendar invites', + value: ( + + ), + }, + { + label: 'Revoke all sessions', + value: ( + + ), + }, + { + label: 'Remove users mailbox permissions', + value: ( + + ), + }, + { + label: 'Remove users calendar permissions', + value: ( + + ), + }, + { + label: 'Remove all Rules', + value: ( + + ), + }, + { + label: 'Reset Password', + value: ( + + ), + }, + { + label: 'Keep copy of forwarded mail in source mailbox', + value: ( + + ), + }, + { + label: 'Delete user', + value: ( + + ), + }, + { + label: 'Remove all Mobile Devices', + value: ( + + ), + }, + { + label: 'Disable Sign in', + value: ( + + ), + }, + { + label: 'Remove all MFA Devices', + value: ( + + ), + }, + { + label: 'Remove Teams Phone DID', + value: ( + + ), + }, + { + label: 'Clear Immutable ID', + value: ( + + ), + }, + { + label: 'Disable OneDrive Sharing Links', + value: ( + + ), + }, + ]} + /> - ); -}; + ) +} diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx index 3810581daea9..ad695d39cace 100644 --- a/src/components/CippComponents/CippPolicyDeployDrawer.jsx +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -201,6 +201,21 @@ export const CippPolicyDeployDrawer = ({ /> + + + + + { + setCacheOverride((prev) => { + const previousValue = prev.tenant === currentTenant ? prev.value : defaultCached; + const nextValue = + typeof valueOrUpdater === "function" ? valueOrUpdater(previousValue) : valueOrUpdater; - // Reset to default whenever tenant changes; AllTenants always forces cached - useEffect(() => { - if (isAllTenants) { - setUseReportDB(true) - } else { - setUseReportDB(defaultCached) - } - }, [currentTenant, isAllTenants, defaultCached]) + return { tenant: currentTenant, value: nextValue }; + }); + }, + [currentTenant, defaultCached], + ); // Whether the toggle is actually clickable const canToggle = allowToggle && !isAllTenants @@ -170,6 +178,7 @@ export function useCippReportDB(config) { relatedQueryKeys: [`${queryKey}-${currentTenant}-true`], data: { Name: cacheName, + ...(cacheName === "Mailboxes" ? { Types: "None" } : {}), ...(syncData || {}), }, onSuccess: handleSyncSuccess, diff --git a/src/components/CippComponents/CippReportToolbar.jsx b/src/components/CippComponents/CippReportToolbar.jsx index 7a25a9976075..bc7a9274a56c 100644 --- a/src/components/CippComponents/CippReportToolbar.jsx +++ b/src/components/CippComponents/CippReportToolbar.jsx @@ -109,13 +109,6 @@ export const CippReportToolbar = () => { onClick={() => { setRefreshDialog({ open: true, - title: 'Refresh Test Data', - message: `Are you sure you want to refresh the test data for ${currentTenant}? This might take up to 2 hours to update.`, - api: { - url: '/api/ExecTestRun', - data: { tenantFilter: currentTenant }, - method: 'POST', - }, handleClose: () => setRefreshDialog({ open: false }), }) }} @@ -187,13 +180,26 @@ export const CippReportToolbar = () => { diff --git a/src/components/CippComponents/CippSpeedDial.jsx b/src/components/CippComponents/CippSpeedDial.jsx index 345632e8ba9e..0a2365bc4707 100644 --- a/src/components/CippComponents/CippSpeedDial.jsx +++ b/src/components/CippComponents/CippSpeedDial.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect } from 'react' import { SpeedDial, SpeedDialAction, @@ -11,10 +11,10 @@ import { Snackbar, Alert, CircularProgress, -} from "@mui/material"; -import { Close as CloseIcon } from "@mui/icons-material"; -import { useForm } from "react-hook-form"; -import { CippFormComponent } from "../../components/CippComponents/CippFormComponent"; +} from '@mui/material' +import { Close as CloseIcon } from '@mui/icons-material' +import { useForm } from 'react-hook-form' +import { CippFormComponent } from '../../components/CippComponents/CippFormComponent' const CippSpeedDial = ({ actions = [], @@ -22,105 +22,106 @@ const CippSpeedDial = ({ icon, openIcon = , }) => { - const [openDialogs, setOpenDialogs] = useState({}); - const [loading, setLoading] = useState(false); - const [showSnackbar, setShowSnackbar] = useState(false); - const [speedDialOpen, setSpeedDialOpen] = useState(false); - const [isHovering, setIsHovering] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(""); + const [openDialogs, setOpenDialogs] = useState({}) + const [loading, setLoading] = useState(false) + const [showSnackbar, setShowSnackbar] = useState(false) + const [speedDialOpen, setSpeedDialOpen] = useState(false) + const [isHovering, setIsHovering] = useState(false) + const [snackbarMessage, setSnackbarMessage] = useState('') const formControls = actions.reduce((acc, action) => { if (action.form) { acc[action.id] = useForm({ - mode: "onChange", + mode: 'onChange', defaultValues: action.form.defaultValues || {}, - }); + }) } - return acc; - }, {}); + return acc + }, {}) const handleSpeedDialClose = (event, reason) => { - if (reason === "toggle") { - setSpeedDialOpen(false); - setIsHovering(false); - return; + if (reason === 'toggle') { + setSpeedDialOpen(false) + setIsHovering(false) + return } if (!isHovering) { setTimeout(() => { - setSpeedDialOpen(false); - }, 200); + setSpeedDialOpen(false) + }, 200) } - }; + } const handleMouseEnter = () => { - setIsHovering(true); - setSpeedDialOpen(true); - }; + setIsHovering(true) + setSpeedDialOpen(true) + } const handleMouseLeave = () => { - setIsHovering(false); - handleSpeedDialClose(); - }; + setIsHovering(false) + handleSpeedDialClose() + } const handleDialogOpen = (actionId) => { - setOpenDialogs((prev) => ({ ...prev, [actionId]: true })); - }; + setOpenDialogs((prev) => ({ ...prev, [actionId]: true })) + } const handleDialogClose = (actionId) => { - setOpenDialogs((prev) => ({ ...prev, [actionId]: false })); - }; + setOpenDialogs((prev) => ({ ...prev, [actionId]: false })) + } const handleSubmit = async (actionId, data) => { - if (!actions.find((a) => a.id === actionId)?.onSubmit) return; + if (!actions.find((a) => a.id === actionId)?.onSubmit) return - setLoading(true); + setLoading(true) try { - const action = actions.find((a) => a.id === actionId); - const result = await action.onSubmit(data); + const action = actions.find((a) => a.id === actionId) + const result = await action.onSubmit(data) if (result.success) { - formControls[actionId]?.reset(); - handleDialogClose(actionId); + formControls[actionId]?.reset() + handleDialogClose(actionId) } - setSnackbarMessage(result.message); - setShowSnackbar(true); + setSnackbarMessage(result.message) + setShowSnackbar(true) } catch (error) { - console.error(`Error submitting ${actionId}:`, error); - setSnackbarMessage("An error occurred while submitting"); - setShowSnackbar(true); + console.error(`Error submitting ${actionId}:`, error) + setSnackbarMessage('An error occurred while submitting') + setShowSnackbar(true) } finally { - setLoading(false); + setLoading(false) } - }; + } useEffect(() => { const handleClickOutside = (event) => { if (speedDialOpen) { - const speedDial = document.querySelector('[aria-label="Navigation SpeedDial"]'); + const speedDial = document.querySelector('[aria-label="Navigation SpeedDial"]') if (speedDial && !speedDial.contains(event.target)) { - setSpeedDialOpen(false); + setSpeedDialOpen(false) } } - }; + } - document.addEventListener("click", handleClickOutside); + document.addEventListener('click', handleClickOutside) return () => { - document.removeEventListener("click", handleClickOutside); - }; - }, [speedDialOpen]); + document.removeEventListener('click', handleClickOutside) + } + }, [speedDialOpen]) return ( <> { if (action.form) { - handleDialogOpen(action.id); + handleDialogOpen(action.id) } else if (action.onClick) { - action.onClick(); + action.onClick() } - setSpeedDialOpen(false); + setSpeedDialOpen(false) }} tooltipOpen sx={{ - "&.MuiSpeedDialAction-fab": { - backgroundColor: "background.paper", - "&:hover": { - backgroundColor: "action.hover", + '&.MuiSpeedDialAction-fab': { + backgroundColor: 'background.paper', + '&:hover': { + backgroundColor: 'action.hover', }, }, - "& .MuiSpeedDialAction-staticTooltipLabel": { - cursor: "pointer", - whiteSpace: "nowrap", - marginRight: "10px", - padding: "6px 10px", - "&:hover": { - backgroundColor: "action.hover", + '& .MuiSpeedDialAction-staticTooltipLabel': { + cursor: 'pointer', + whiteSpace: 'nowrap', + marginRight: '10px', + padding: '6px 10px', + '&:hover': { + backgroundColor: 'action.hover', }, }, }} @@ -183,10 +184,10 @@ const CippSpeedDial = ({ name={action.form.fieldName} required formControl={formControls[action.id]} - style={{ minHeight: "150px" }} + style={{ minHeight: '150px' }} editorProps={{ attributes: { - style: "min-height: 150px; font-size: 1.1rem; padding: 1rem;", + style: 'min-height: 150px; font-size: 1.1rem; padding: 1rem;', }, }} /> @@ -204,7 +205,7 @@ const CippSpeedDial = ({ disabled={loading} startIcon={loading ? : null} > - {loading ? "Submitting..." : action.form.submitText || "Submit"} + {loading ? 'Submitting...' : action.form.submitText || 'Submit'} @@ -214,14 +215,14 @@ const CippSpeedDial = ({ open={showSnackbar} autoHideDuration={6000} onClose={() => setShowSnackbar(false)} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > - setShowSnackbar(false)} severity="success" sx={{ width: "100%" }}> + setShowSnackbar(false)} severity="success" sx={{ width: '100%' }}> {snackbarMessage} - ); -}; + ) +} -export default CippSpeedDial; +export default CippSpeedDial diff --git a/src/components/CippComponents/CippTemplateFieldRenderer.jsx b/src/components/CippComponents/CippTemplateFieldRenderer.jsx index 1757e400acda..8ecef26973eb 100644 --- a/src/components/CippComponents/CippTemplateFieldRenderer.jsx +++ b/src/components/CippComponents/CippTemplateFieldRenderer.jsx @@ -244,11 +244,43 @@ const CippTemplateFieldRenderer = ({ React.useEffect(() => { if (templateData && formControl) { const processedData = parseIntuneRawJson(templateData); - const formValues = {}; + // Recursively strip null values, empty arrays, empty strings, + // and @odata / Graph metadata keys so they don't create blank + // form fields or phantom sections in the builder. + const stripEmpty = (obj) => { + if (obj === null || obj === undefined) return undefined; + if (typeof obj === "string" && obj.trim() === "") return undefined; + if (Array.isArray(obj)) { + const filtered = obj + .map(stripEmpty) + .filter((v) => v !== undefined && v !== null); + return filtered.length > 0 ? filtered : undefined; + } + if (typeof obj === "object") { + const result = {}; + let hasContent = false; + for (const [k, v] of Object.entries(obj)) { + // Drop @odata annotations and Graph metadata + if (k.includes("@odata") || k.startsWith("#")) continue; + const cleaned = stripEmpty(v); + if (cleaned !== undefined) { + result[k] = cleaned; + hasContent = true; + } + } + return hasContent ? result : undefined; + } + return obj; + }; + + const formValues = {}; Object.keys(processedData).forEach((key) => { if (!isFieldBlacklisted(key)) { - formValues[key] = processedData[key]; + const cleaned = stripEmpty(processedData[key]); + if (cleaned !== undefined) { + formValues[key] = cleaned; + } } }); formControl.reset(formValues); @@ -258,6 +290,10 @@ const CippTemplateFieldRenderer = ({ const renderFormField = (key, value, path = "") => { const fieldPath = path ? `${path}.${key}` : key; + // Skip null/undefined values and @odata / metadata keys + if (value === null || value === undefined) return null; + if (key.includes("@odata") || key.startsWith("#")) return null; + if (isFieldBlacklisted(key)) { return null; } @@ -776,12 +812,16 @@ const CippTemplateFieldRenderer = ({ {priorityFields.map( (fieldName) => processedData[fieldName] !== undefined && + processedData[fieldName] !== null && renderFormField(fieldName, processedData[fieldName]) )} {/* Render all other fields except priority fields */} {Object.entries(processedData) - .filter(([key]) => !priorityFields.includes(key)) + .filter( + ([key, value]) => + !priorityFields.includes(key) && value !== null && value !== undefined + ) .map(([key, value]) => renderFormField(key, value))} ); diff --git a/src/components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx b/src/components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx index cc0ecf78b506..0e28dce6d188 100644 --- a/src/components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx +++ b/src/components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx @@ -23,13 +23,41 @@ export const CippTenantAllowBlockListTemplateDrawer = ({ buttonText = 'Add Template', requiredPermissions = [], PermissionButton = Button, + editData = null, + drawerVisible: controlledDrawerVisible, + setDrawerVisible: controlledSetDrawerVisible, }) => { - const [drawerVisible, setDrawerVisible] = useState(false) + const [internalDrawerVisible, internalSetDrawerVisible] = useState(false) + const drawerVisible = + controlledDrawerVisible !== undefined ? controlledDrawerVisible : internalDrawerVisible + const setDrawerVisible = + controlledSetDrawerVisible !== undefined ? controlledSetDrawerVisible : internalSetDrawerVisible + + const isEditMode = !!editData + const formControl = useForm({ mode: 'onChange', defaultValues, }) + useEffect(() => { + if (editData && drawerVisible) { + formControl.reset({ + templateName: editData.templateName || '', + entries: Array.isArray(editData.entries) + ? editData.entries.join(', ') + : editData.entries || '', + notes: editData.notes || '', + listType: editData.listType ? { label: editData.listType, value: editData.listType } : null, + listMethod: editData.listMethod + ? { label: editData.listMethod, value: editData.listMethod } + : null, + NoExpiration: editData.NoExpiration || false, + RemoveAfter: editData.RemoveAfter || false, + }) + } + }, [editData, drawerVisible, formControl]) + const { isValid } = useFormState({ control: formControl.control }) const noExpiration = useWatch({ control: formControl.control, name: 'NoExpiration' }) @@ -197,10 +225,18 @@ export const CippTenantAllowBlockListTemplateDrawer = ({ RemoveAfter: values.RemoveAfter, } - saveTemplate.mutate({ - url: '/api/AddTenantAllowBlockListTemplate', - data: payload, - }) + if (isEditMode && editData?.GUID) { + payload.GUID = editData.GUID + saveTemplate.mutate({ + url: '/api/EditTenantAllowBlockListTemplate', + data: payload, + }) + } else { + saveTemplate.mutate({ + url: '/api/AddTenantAllowBlockListTemplate', + data: payload, + }) + } }) const handleCloseDrawer = () => { @@ -210,16 +246,22 @@ export const CippTenantAllowBlockListTemplateDrawer = ({ return ( <> - setDrawerVisible(true)} - startIcon={} - > - {buttonText} - + {!isEditMode && controlledDrawerVisible === undefined && ( + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + )} + + + ) +} + +export default CippTutorialDialog diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index a434d7925fb3..e635c5fa988f 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -20,6 +20,7 @@ import { Shortcut, EditAttributes, CloudSync, + Share, } from '@mui/icons-material' import { getCippLicenseTranslation } from '../../utils/get-cipp-license-translation' import { useSettings } from '../../hooks/use-settings.js' @@ -417,7 +418,7 @@ export const useCippUserActions = () => { }, { //tested - label: 'Create Temporary Access Password', + label: 'Create Temporary Access Pass', type: 'POST', icon: , url: '/api/ExecCreateTAP', @@ -442,7 +443,7 @@ export const useCippUserActions = () => { }, ], confirmText: - 'Are you sure you want to create a Temporary Access Password for [userPrincipalName]?', + 'Are you sure you want to create a Temporary Access Pass for [userPrincipalName]?', multiPost: false, condition: () => canWriteUser, }, @@ -654,6 +655,41 @@ export const useCippUserActions = () => { multiPost: false, condition: () => canWriteUser, }, + { + label: 'Set OneDrive External Sharing', + type: 'POST', + icon: , + url: '/api/ExecSetOneDriveSharing', + data: { UPN: 'userPrincipalName' }, + fields: [ + { + type: 'autoComplete', + name: 'SharingCapability', + label: 'Sharing Level', + multiple: false, + creatable: false, + validators: { required: 'Please select a sharing level' }, + options: [ + { label: 'Disabled - No external sharing allowed', value: 'Disabled' }, + { + label: 'External User Sharing Only - Guests must sign in', + value: 'ExternalUserSharingOnly', + }, + { + label: 'External User and Guest Sharing - Anyone links allowed', + value: 'ExternalUserAndGuestSharing', + }, + { + label: 'Existing External User Sharing Only - Existing guests only', + value: 'ExistingExternalUserSharingOnly', + }, + ], + }, + ], + confirmText: "Select the sharing level for [userPrincipalName]'s OneDrive:", + multiPost: false, + condition: () => canWriteUser, + }, { label: 'Add OneDrive Shortcut', type: 'POST', diff --git a/src/components/CippComponents/EnrollmentProfileTabs.jsx b/src/components/CippComponents/EnrollmentProfileTabs.jsx new file mode 100644 index 000000000000..4499d1c94962 --- /dev/null +++ b/src/components/CippComponents/EnrollmentProfileTabs.jsx @@ -0,0 +1,466 @@ +import { useMemo, useState } from 'react' +import { + Alert, + Button, + Card, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '@mui/material' +import { Box, Container, Stack } from '@mui/system' +import { + AccountTree, + Apple, + ContentCopy, + Delete, + EventAvailable, + QrCode2, + Sync, +} from '@mui/icons-material' +import { CippHead } from './CippHead.jsx' +import { CippDataTable } from '../CippTable/CippDataTable.js' +import { CippInfoBar } from '../CippCards/CippInfoBar.jsx' +import { CippApiDialog } from './CippApiDialog.jsx' +import { CippAutopilotProfileDrawer } from './CippAutopilotProfileDrawer.jsx' +import CippJsonView from '../CippFormPages/CippJSONView.jsx' +import { ApiGetCall } from '../../api/ApiCall.jsx' +import { useDialog } from '../../hooks/use-dialog.js' +import { useSettings } from '../../hooks/use-settings.js' + +const pageTitle = 'Enrollment Profiles' +const appleADEPageTitle = 'Apple Enrollment Profiles' +const androidEnterprisePageTitle = 'Android Enrollment Profiles' +const windowsAutopilotPageTitle = 'Windows Autopilot Profiles' + +const EnrollmentProfilesPage = ({ children, title = pageTitle }) => { + return ( + <> + + + + + {children} + + + + + ) +} + +const AndroidQrDialog = ({ row, drawerVisible, setDrawerVisible }) => { + const [copied, setCopied] = useState(false) + + const tokenValue = useMemo(() => { + if (row?.tokenValue) { + return row.tokenValue + } + + if (!row?.qrCodeContent) { + return '' + } + + try { + const parsed = JSON.parse(row.qrCodeContent) + const adminExtras = parsed?.['android.app.extra.PROVISIONING_ADMIN_EXTRAS_BUNDLE'] + return adminExtras?.['com.google.android.apps.work.clouddpc.EXTRA_ENROLLMENT_TOKEN'] || '' + } catch { + return '' + } + }, [row?.qrCodeContent, row?.tokenValue]) + + const handleClose = () => { + setDrawerVisible(false) + } + + const handleCopy = async () => { + if (!tokenValue) { + return + } + + try { + await navigator.clipboard.writeText(tokenValue) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + setCopied(false) + } + } + + const qrCodeImageValue = row?.qrCodeImage?.value + const qrCodeImageType = row?.qrCodeImage?.type || 'image/png' + + return ( + + Enrollment QR - {row?.displayName} + + {qrCodeImageValue && ( + + Enrollment QR code + + )} + + Token value + + + + {tokenValue || 'No token value available.'} + + + + + + + ) +} + +export const AppleADEEnrollmentProfiles = () => { + const currentTenant = useSettings().currentTenant + const depSyncDialog = useDialog() + + const appleProfiles = ApiGetCall({ + url: '/api/ListAppleEnrollmentProfiles', + data: { tenantFilter: currentTenant }, + queryKey: `AppleEnrollmentProfiles-${currentTenant}`, + waiting: Boolean(currentTenant), + }) + + const appleData = appleProfiles.data?.Results || {} + const tokens = appleData.Tokens || [] + const profiles = appleData.Profiles || [] + const syncErrorCodes = { + 1: { + label: 'Expired', + message: 'The ADE token sync has expired.', + severity: 'error', + }, + 2: { + label: 'Unknown', + message: 'The ADE token sync state is unknown.', + severity: 'error', + }, + 3: { + label: 'Terms & Conditions', + message: 'New Apple Business Manager terms are ready to accept.', + severity: 'warning', + }, + 4: { + label: 'Warning', + message: 'The ADE token sync completed with a warning.', + severity: 'warning', + }, + } + const syncErrorTokens = tokens.filter( + (token) => token.lastSyncErrorCode != null && Number(token.lastSyncErrorCode) !== 0 + ) + const expiringTokens = tokens.filter( + (token) => token.daysUntilExpiration !== null && token.daysUntilExpiration <= 30 + ) + const totalSyncedDevices = tokens.reduce( + (sum, token) => sum + Number(token.syncedDeviceCount || 0), + 0 + ) + const lastSuccessfulSync = tokens + .map((token) => token.lastSuccessfulSyncDateTime) + .filter(Boolean) + .sort() + .pop() + + const infoBarData = [ + { + icon: , + name: 'ADE Tokens', + data: tokens.length, + offcanvas: { + title: 'Apple ADE Tokens', + propertyItems: tokens.flatMap((token) => [ + { + label: `${token.tokenName || token.id} - Apple ID`, + value: token.appleIdentifier || 'N/A', + }, + { + label: `${token.tokenName || token.id} - Expiration`, + value: token.tokenExpirationDateTime || 'N/A', + }, + { + label: `${token.tokenName || token.id} - Synced Devices`, + value: token.syncedDeviceCount ?? 'N/A', + }, + { + label: `${token.tokenName || token.id} - Last Sync`, + value: token.lastSuccessfulSyncDateTime || 'N/A', + }, + ]), + }, + }, + { + icon: , + name: 'Expiring Tokens', + data: expiringTokens.length, + color: expiringTokens.length ? 'error' : 'success', + toolTip: 'Tokens expiring within 30 days', + }, + { + icon: , + name: 'ADE Profiles', + data: profiles.length, + }, + { + icon: , + name: 'Last Successful Sync', + data: lastSuccessfulSync ? new Date(lastSuccessfulSync).toLocaleString() : 'N/A', + toolTip: `${totalSyncedDevices} synced devices across all tokens`, + }, + ] + + const appleActions = [ + { + label: 'Delete Profile', + type: 'POST', + icon: , + url: '/api/ExecRemoveEnrollmentProfile', + relatedQueryKeys: [`AppleEnrollmentProfiles*-${currentTenant}`], + data: { + profileId: 'id', + tokenId: 'tokenId', + profileType: 'profileType', + displayName: 'displayName', + }, + confirmText: 'Are you sure you want to delete enrollment profile [displayName]?', + color: 'danger', + }, + ] + + const appleFilters = useMemo( + () => [ + { filterName: 'All', value: [] }, + { filterName: 'macOS', value: [{ id: 'platform', value: 'macOS' }] }, + { + filterName: 'iOS/iPadOS', + value: [{ id: 'platform', value: 'iOS/iPadOS' }], + }, + ], + [] + ) + + return ( + <> + + {!appleProfiles.isFetching && + syncErrorTokens.map((token, index) => { + const errorCode = Number(token.lastSyncErrorCode) + const syncError = syncErrorCodes[errorCode] || { + label: 'Unknown Error', + message: 'The ADE token sync was not successful.', + severity: 'warning', + } + const tokenName = token.tokenName || token.id || 'Unknown token' + const appleIdentifier = token.appleIdentifier ? ` (${token.appleIdentifier})` : '' + const lastSuccessfulSyncText = token.lastSuccessfulSyncDateTime + ? ` Last successful sync: ${new Date( + token.lastSuccessfulSyncDateTime + ).toLocaleString()}.` + : '' + + return ( + + {`Token "${tokenName}"${appleIdentifier}: ${syncError.message} (Code ${errorCode} - ${syncError.label}).${lastSuccessfulSyncText}`} + + ) + })} + + + + + + + , + size: 'xl', + }} + cardButton={ + + + + } + /> + + + + + ) +} + +export const AndroidEnterpriseEnrollmentProfiles = () => { + const currentTenant = useSettings().currentTenant + const androidActions = [ + { + label: 'Show QR', + icon: , + hideBulk: true, + noConfirm: true, + condition: (row) => Boolean(row?.tokenValue || row?.qrCodeImage?.value || row?.qrCodeContent), + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), + }, + { + label: 'Delete Profile', + type: 'POST', + icon: , + url: '/api/ExecRemoveEnrollmentProfile', + data: { + profileId: 'id', + profileType: '!android', + displayName: 'displayName', + }, + confirmText: 'Are you sure you want to delete Android enrollment profile [displayName]?', + color: 'danger', + }, + ] + + return ( + + + , + size: 'xl', + }} + /> + + + ) +} + +export const WindowsAutopilotEnrollmentProfiles = () => { + const currentTenant = useSettings().currentTenant + const autopilotActions = [ + { + label: 'Delete Profile', + icon: , + type: 'POST', + url: '/api/RemoveAutopilotConfig', + data: { ID: 'id', displayName: 'displayName', assignments: 'assignments' }, + confirmText: + 'Are you sure you want to delete this Autopilot profile? This action cannot be undone.', + color: 'danger', + }, + ] + + return ( + + + , + size: 'xl', + }} + cardButton={} + /> + + + ) +} diff --git a/src/components/CippComponents/FailedPaymentDialog.jsx b/src/components/CippComponents/FailedPaymentDialog.jsx new file mode 100644 index 000000000000..5668f619c443 --- /dev/null +++ b/src/components/CippComponents/FailedPaymentDialog.jsx @@ -0,0 +1,49 @@ +import { useEffect, useState, useCallback } from 'react' +import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material' + +const DISMISS_KEY = 'cipp_hosted_payment_dismissed' +const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day + +export const FailedPaymentDialog = ({ hostedFailedPayments }) => { + const [open, setOpen] = useState(false) + + useEffect(() => { + if (!hostedFailedPayments) return + + const dismissedAt = localStorage.getItem(DISMISS_KEY) + if (dismissedAt && Date.now() - Number(dismissedAt) < DISMISS_DURATION_MS) return + + setOpen(true) + }, [hostedFailedPayments]) + + const handleDismiss = useCallback(() => { + localStorage.setItem(DISMISS_KEY, String(Date.now())) + setOpen(false) + }, []) + + return ( + e.stopPropagation() } }} + > + Payment Issue + + + There is a payment issue with your CIPP subscription. + + + A recent payment has failed. Please contact your account holder to update payment + information and avoid service interruption. + + + + + + + ) +} diff --git a/src/components/CippComponents/ForcedSsoMigrationDialog.jsx b/src/components/CippComponents/ForcedSsoMigrationDialog.jsx new file mode 100644 index 000000000000..7326ae56df0c --- /dev/null +++ b/src/components/CippComponents/ForcedSsoMigrationDialog.jsx @@ -0,0 +1,129 @@ +import { useCallback, useState } from 'react' +import { + Alert, + Box, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Switch, + Typography, + Button, +} from '@mui/material' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' + +export const ForcedSsoMigrationDialog = () => { + const [multiTenant, setMultiTenant] = useState(false) + const [submitted, setSubmitted] = useState(false) + + const currentRole = ApiGetCall({ + url: '/api/me', + queryKey: 'authmecipp', + }) + + const ssoSetup = ApiPostCall({ + relatedQueryKeys: 'authmecipp', + }) + + const permissions = currentRole.data?.permissions || [] + const forceSsoMigration = currentRole.data?.forceSsoMigration + const hasPermission = permissions.includes('CIPP.AppSettings.ReadWrite') + + const open = !!(currentRole.isSuccess && hasPermission && forceSsoMigration?.status === 'pending') + + const result = ssoSetup.data?.data?.Results ?? ssoSetup.data?.Results + const isSuccess = result?.severity === 'success' + const isError = ssoSetup.isError || result?.severity === 'failed' + + const handleMigrate = useCallback(() => { + setSubmitted(true) + ssoSetup.mutate({ + url: '/api/ExecSSOSetup', + data: { + Action: 'Migrate', + multiTenant, + }, + }) + }, [multiTenant, ssoSetup]) + + return ( + e.stopPropagation() } }} + > + Complete Authentication Setup + + {!submitted ? ( + <> + + Your CIPP instance requires a dedicated CIPP-SSO app registration in + your tenant for authentication. This gives you full control over Conditional Access + policies, MFA requirements, and session management for your CIPP users. + + + The app will only require minimal permissions (OpenID, Profile, Email). + + + This step is required before you can use CIPP. + + + setMultiTenant(e.target.checked)} /> + } + label="Multi-tenant mode (allow users from multiple Entra ID tenants to log in)" + sx={{ mb: 1 }} + /> + + ) : isSuccess ? ( + + SSO migration complete. The application will restart to apply the new authentication + configuration. This may take a couple of minutes — you will be prompted to log in again + once the restart is finished. + + ) : ssoSetup.isPending ? ( + + + Creating CIPP-SSO app and configuring authentication... + + ) : isError ? ( + <> + + {result?.message || + ssoSetup.error?.message || + 'SSO migration failed. Please try again.'} + + + If this error persists, contact your CIPP administrator. + + + ) : null} + + + {!submitted ? ( + + ) : isSuccess ? ( + + ) : isError ? ( + + ) : null} + + + ) +} diff --git a/src/components/CippComponents/SsoMigrationDialog.jsx b/src/components/CippComponents/SsoMigrationDialog.jsx new file mode 100644 index 000000000000..03026daec36a --- /dev/null +++ b/src/components/CippComponents/SsoMigrationDialog.jsx @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Switch, + Typography, +} from '@mui/material' +import { ApiPostCall } from '../../api/ApiCall' + +const DISMISS_KEY = 'cipp_sso_migration_dismissed' + +export const SsoMigrationDialog = ({ meData }) => { + const [open, setOpen] = useState(false) + const [multiTenant, setMultiTenant] = useState(false) + const [submitted, setSubmitted] = useState(false) + + const ssoSetup = ApiPostCall({ + relatedQueryKeys: 'authmecipp', + }) + + const permissions = meData?.permissions || [] + const ssoMigration = meData?.ssoMigration + const hasPermission = permissions.includes('CIPP.AppSettings.ReadWrite') + + useEffect(() => { + if (!meData || !ssoMigration) return + if (ssoMigration.status !== 'none') return + + const dismissedAt = localStorage.getItem(DISMISS_KEY) + if (dismissedAt && Date.now() - Number(dismissedAt) < 24 * 60 * 60 * 1000) return + + setOpen(true) + }, [meData, ssoMigration]) + + const handleApprove = useCallback(() => { + setSubmitted(true) + ssoSetup.mutate({ + url: '/api/ExecSSOSetup', + data: { + Action: 'Create', + multiTenant, + }, + }) + }, [multiTenant, ssoSetup]) + + const handleDismiss = useCallback(() => { + localStorage.setItem(DISMISS_KEY, String(Date.now())) + setOpen(false) + }, []) + + const handleClose = useCallback(() => { + setOpen(false) + }, []) + + const result = ssoSetup.data?.data?.Results ?? ssoSetup.data?.Results + const isSuccess = result?.severity === 'success' + const isError = ssoSetup.isError || result?.severity === 'failed' + + return ( + + Prepare for CIPP Single Sign-On + + {!submitted ? ( + <> + + CIPP will soon be moving to a dedicated Single Sign-On model, giving you full control + over Conditional Access policies, MFA requirements, and session management for your + CIPP users. + + + To get ready, CIPP needs to create an app registration in your tenant called + CIPP-SSO with minimal permissions (OpenID, Profile, Email only). + This won't change how you log in today — it just prepares your tenant for when + the update rolls out. + + + Review the options below and click "Create App Registration" to get set up + ahead of time. + + + {!hasPermission && ( + + Only users with App Settings permissions can create the SSO app registration. + Please ask an administrator to complete this step. + + )} + + setMultiTenant(e.target.checked)} /> + } + label="Multi-tenant mode (allow users from multiple Entra ID tenants to log in)" + sx={{ mb: 1 }} + /> + + ) : ssoSetup.isPending ? ( + <> + + Creating CIPP-SSO app registration... + + ) : isSuccess ? ( + + {result.message} + + ) : isError ? ( + + {result?.message || ssoSetup.error?.message || 'SSO setup failed. It will be retried automatically.'} + + ) : null} + + + {!submitted ? ( + <> + + + + ) : ( + + )} + + + ) +} diff --git a/src/components/CippComponents/SubscriptionEndedDialog.jsx b/src/components/CippComponents/SubscriptionEndedDialog.jsx new file mode 100644 index 000000000000..e715cce54d4e --- /dev/null +++ b/src/components/CippComponents/SubscriptionEndedDialog.jsx @@ -0,0 +1,25 @@ +import { Alert, Dialog, DialogContent, DialogTitle, Typography } from '@mui/material' + +export const SubscriptionEndedDialog = ({ hostedSubscriptionEnded }) => { + const open = !!hostedSubscriptionEnded + + return ( + e.stopPropagation() } }} + > + Subscription Ended + + + Your CIPP subscription has ended. Access to this instance is no longer available. + + + Please contact your account holder to renew the subscription and restore access. + + + + ) +} diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 1e4ac2353fd8..ad578556a517 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -351,6 +351,15 @@ const CippAddEditUser = (props) => { } } } + + // Populate custom user attributes from template + if (template.defaultAttributes) { + Object.entries(template.defaultAttributes).forEach(([key, attr]) => { + if (attr?.Value) { + setFieldIfEmpty(`defaultAttributes.${key}.Value`, attr.Value) + } + }) + } } }, [watchedFields.userTemplate, formType]) diff --git a/src/components/CippFormPages/CippAddGroupForm.jsx b/src/components/CippFormPages/CippAddGroupForm.jsx index 713bb414c638..6ce4b08ce3bc 100644 --- a/src/components/CippFormPages/CippAddGroupForm.jsx +++ b/src/components/CippFormPages/CippAddGroupForm.jsx @@ -4,6 +4,7 @@ import CippFormComponent from "../CippComponents/CippFormComponent"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "../CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; +import { CippFormLicenseSelector } from "../CippComponents/CippFormLicenseSelector"; const DynamicMembershipRules = ({ formControl }) => ( @@ -100,6 +101,21 @@ const CippAddGroupForm = (props) => { ]} /> + + + + + { const { formControl } = props; @@ -66,6 +67,21 @@ const CippAddGroupTemplateForm = (props) => { {/* Debug output */}
    Current groupType: {formControl.watch("groupType")}
    + + + + + { + const router = useRouter(); const [openAddClientDialog, setOpenAddClientDialog] = useState(false); const [openAddExistingAppDialog, setOpenAddExistingAppDialog] = useState(false); const [addClientRetryPayload, setAddClientRetryPayload] = useState(null); @@ -45,6 +47,46 @@ const CippApiClientManagement = () => { queryKey: "ApiClients", }); + const hasUnsavedChanges = useMemo(() => { + if (!azureConfig.isSuccess || !apiClients.isSuccess) return false; + return !isEqual( + (apiClients.data?.pages?.[0]?.Results || []) + .filter((c) => c.Enabled) + .map((c) => c.ClientId) + .sort(), + (azureConfig.data?.Results?.ClientIDs || []).sort() + ); + }, [azureConfig.isSuccess, azureConfig.data, apiClients.isSuccess, apiClients.data]); + + useEffect(() => { + const handleBeforeUnload = (e) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ""; + } + }; + + const handleRouteChange = (url) => { + if ( + hasUnsavedChanges && + !window.confirm( + "You have unsaved API client changes. Are you sure you want to leave this page?" + ) + ) { + router.events.emit("routeChangeError"); + throw "Route change aborted due to unsaved changes."; + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + router.events.on("routeChangeStart", handleRouteChange); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + router.events.off("routeChangeStart", handleRouteChange); + }; + }, [hasUnsavedChanges, router.events]); + const handleMenuOpen = (event) => { setMenuAnchorEl(event.currentTarget); }; @@ -54,11 +96,18 @@ const CippApiClientManagement = () => { }; const handleSaveToAzure = () => { + handleMenuClose(); + if ( + !window.confirm( + "Saving to Azure will restart the CIPP instance. Changes may take up to 60 seconds to reflect. Do you want to continue?" + ) + ) { + return; + } postCall.mutate({ url: `/api/ExecApiClient?action=SaveToAzure`, data: {}, }); - handleMenuClose(); }; const getRetryPayload = (result) => { @@ -130,6 +179,18 @@ const CippApiClientManagement = () => { name: "Enabled", label: "Enable this client", }, + { + type: "switch", + name: "MCPAllowed", + label: "MCP Access Allowed", + }, + { + type: "alert", + name: "mcpAccessWarning", + severity: "warning", + label: + "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role.", + }, ], type: "POST", url: "/api/ExecApiClient", @@ -284,13 +345,7 @@ const CippApiClientManagement = () => { /> {azureConfig.isSuccess && apiClients.isSuccess && ( <> - {!isEqual( - (apiClients.data?.pages?.[0]?.Results || []) - .filter((c) => c.Enabled) - .map((c) => c.ClientId) - .sort(), - (azureConfig.data?.Results?.ClientIDs || []).sort() - ) && ( + {hasUnsavedChanges && ( You have unsaved changes. Click Actions > Save Azure Configuration to update @@ -320,7 +375,7 @@ const CippApiClientManagement = () => { data: { Action: "List" }, dataKey: "Results", }} - simpleColumns={["Enabled", "AppName", "ClientId", "Role", "IPRange"]} + simpleColumns={["Enabled", "MCPAllowed", "AppName", "ClientId", "Role", "IPRange"]} queryKey={`ApiClients`} /> @@ -374,6 +429,18 @@ const CippApiClientManagement = () => { name: "Enabled", label: "Enable this client", }, + { + type: "switch", + name: "MCPAllowed", + label: "MCP Access Allowed", + }, + { + type: "alert", + name: "mcpAccessWarning", + severity: "warning", + label: + "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role.", + }, ]} api={{ type: "POST", @@ -443,6 +510,18 @@ const CippApiClientManagement = () => { name: "Enabled", label: "Enable this client", }, + { + type: "switch", + name: "MCPAllowed", + label: "MCP Access Allowed", + }, + { + type: "alert", + name: "mcpAccessWarning", + severity: "warning", + label: + "Enabling MCP Access converts this client into the MCP resource app — it can no longer be used as a normal API client, and only one client per tenant can hold this role.", + }, ]} api={{ type: "POST", diff --git a/src/components/CippSettings/CippContainerManagement.jsx b/src/components/CippSettings/CippContainerManagement.jsx new file mode 100644 index 000000000000..37daf6aa3e45 --- /dev/null +++ b/src/components/CippSettings/CippContainerManagement.jsx @@ -0,0 +1,442 @@ +import { useEffect } from "react"; +import { + Alert, + Button, + CardActions, + CardContent, + Chip, + Divider, + Skeleton, + Stack, + Typography, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { CippApiResults } from "../CippComponents/CippApiResults"; + +const channelLabels = { + latest: { label: "Latest (Stable)", color: "success" }, + dev: { label: "Dev", color: "warning" }, + nightly: { label: "Nightly", color: "info" }, + unknown: { label: "Unknown", color: "default" }, +}; + +const intervalOptions = [ + { label: "Disabled", value: "0" }, + { label: "Every hour", value: "1h" }, + { label: "Every 4 hours", value: "4h" }, + { label: "Every 12 hours", value: "12h" }, + { label: "Every day", value: "1d" }, +]; + +const hourOptions = Array.from({ length: 24 }, (_, i) => ({ + label: `${i.toString().padStart(2, "0")}:00`, + value: String(i), +})); + +export const CippContainerManagement = () => { + const channelForm = useForm({ + mode: "onChange", + defaultValues: { Channel: null }, + }); + + const updateSettingsForm = useForm({ + mode: "onChange", + defaultValues: { CheckInterval: null, AutoUpdate: false, CheckTime: null }, + }); + + const containerStatus = ApiGetCall({ + url: "/api/ExecContainerManagement", + data: { Action: "Status" }, + queryKey: "containerStatus", + }); + + const channelAction = ApiPostCall({ + relatedQueryKeys: ["containerStatus"], + }); + + const restartAction = ApiPostCall({ + relatedQueryKeys: ["containerStatus"], + }); + + const updateCheckAction = ApiPostCall({ + relatedQueryKeys: ["containerStatus"], + }); + + const updateSettingsAction = ApiPostCall({ + relatedQueryKeys: ["containerStatus"], + }); + + const data = containerStatus.data?.Results; + const channelInfo = channelLabels[data?.CurrentChannel] ?? channelLabels.unknown; + const updateSettings = data?.UpdateSettings; + + const channelOptions = (data?.ValidChannels ?? ["latest", "dev", "nightly"]).map((c) => ({ + label: channelLabels[c]?.label ?? c, + value: c, + })); + + useEffect(() => { + if (containerStatus.isSuccess && data?.CurrentChannel) { + const current = channelOptions.find((o) => o.value === data.CurrentChannel); + if (current) { + channelForm.reset({ Channel: current }); + } + } + }, [containerStatus.isSuccess, data?.CurrentChannel]); + + useEffect(() => { + if (containerStatus.isSuccess && updateSettings) { + const interval = intervalOptions.find((o) => o.value === (updateSettings.CheckInterval ?? "0")); + const hour = updateSettings.CheckTime != null + ? hourOptions.find((o) => o.value === String(updateSettings.CheckTime)) + : null; + updateSettingsForm.reset({ + CheckInterval: interval ?? intervalOptions[0], + AutoUpdate: updateSettings.AutoUpdate ?? false, + CheckTime: hour ?? null, + }); + } + }, [containerStatus.isSuccess, updateSettings?.CheckInterval, updateSettings?.AutoUpdate, updateSettings?.CheckTime]); + + const handleUpdateChannel = () => { + const selected = channelForm.getValues("Channel"); + const channel = selected?.value ?? selected; + channelAction.mutate({ + url: "/api/ExecContainerManagement", + data: { Action: "UpdateChannel", Channel: channel }, + }); + }; + + const handleRestart = () => { + restartAction.mutate({ + url: "/api/ExecContainerManagement", + data: { Action: "Restart" }, + }); + }; + + const handleCheckUpdate = () => { + updateCheckAction.mutate({ + url: "/api/ExecContainerManagement", + data: { Action: "CheckUpdate" }, + }); + }; + + const handleSaveUpdateSettings = () => { + const interval = updateSettingsForm.getValues("CheckInterval"); + const autoUpdate = updateSettingsForm.getValues("AutoUpdate"); + const checkTime = updateSettingsForm.getValues("CheckTime"); + updateSettingsAction.mutate({ + url: "/api/ExecContainerManagement", + data: { + Action: "SaveUpdateSettings", + CheckInterval: interval?.value ?? interval ?? "0", + AutoUpdate: autoUpdate ?? false, + CheckTime: checkTime?.value ?? checkTime ?? null, + }, + }); + }; + + const truncateDigest = (digest) => { + if (!digest) return "—"; + // Show algo prefix + first 12 hex chars + if (digest.startsWith("sha256:")) { + return `sha256:${digest.slice(7, 19)}…`; + } + return digest.length > 20 ? `${digest.slice(0, 20)}…` : digest; + }; + + return ( + + + + + {containerStatus.isLoading ? ( + + + + + ) : ( + + {data?.ConfiguredChannel && data.ConfiguredChannel !== data.CurrentChannel && ( + + A channel change is pending. Running: {data.CurrentChannel}, + configured: {data.ConfiguredChannel}. Restart the container to + apply. + + )} + {updateSettings?.UpdateAvailable && ( + + A container update is available. Restart the container to pull the latest image. + + )} + + + + Running Channel + + + + + + + + + Image Tag + + + + + {data?.ImageTag ?? "unknown"} + + + + + + App Version + + + + + {data?.CurrentVersion ?? "unknown"} + + + + + + Commit SHA + + + + + {data?.CommitSha ?? "unknown"} + + + + {updateSettings?.RunningDigest && ( + <> + + + Container Digest + + + + + {truncateDigest(updateSettings.RunningDigest)} + + + + )} + + {data?.CurrentImage && data.CurrentImage !== "unknown" && ( + <> + + + Container Image + + + + + {data.CurrentImage} + + + + )} + + {data?.SiteName && ( + <> + + + App Service + + + + {data.SiteName} + + + )} + + + )} + + + + + + + + + + Configure automatic update checking. CIPP will query the container registry for a new + image digest and optionally restart the container to apply the update. + NOTE: If the container restarts for any reason the latest image version for your update channel will be pulled regardless + + + + + + + + + + + + + {updateSettings?.LastCheck && ( + + Last checked: {new Date(updateSettings.LastCheck * 1000).toLocaleString()} + {updateSettings.UpdateAvailable ? ( + + ) : ( + + )} + + )} + + {updateSettings?.RunningDigest && updateSettings?.RemoteDigest && ( + + + + Running Digest + + + + + {truncateDigest(updateSettings.RunningDigest)} + + + + + Remote Digest + + + + + {truncateDigest(updateSettings.RemoteDigest)} + + + + )} + + + + + + + + + + + + + + + + + Changing the release channel updates the container image tag. The new image will be + pulled on the next container restart. Switching to "Dev" or + "Nightly" may include unstable or untested changes. + + + + + + + + + + + + + + + + + Restart the application container. This will cause a brief downtime while the container + restarts. If you changed the release channel, this will pull the new image. + + + + + + + + + + + ); +}; + +export default CippContainerManagement; diff --git a/src/components/CippSettings/CippRoleAddEdit.jsx b/src/components/CippSettings/CippRoleAddEdit.jsx index 757215cd0f49..75192d394004 100644 --- a/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/src/components/CippSettings/CippRoleAddEdit.jsx @@ -41,6 +41,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const formControl = useForm({ mode: "onChange", + defaultValues: { + allowedTenants: [], + blockedTenants: [], + BlockedEndpoints: [], + IPRange: [], + Permissions: {}, + }, }); const formState = useFormState({ control: formControl.control }); diff --git a/src/components/CippSettings/CippSSOSettings.jsx b/src/components/CippSettings/CippSSOSettings.jsx new file mode 100644 index 000000000000..e22cd1ab6edf --- /dev/null +++ b/src/components/CippSettings/CippSSOSettings.jsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from "react"; +import { + Alert, + Button, + CardActions, + CardContent, + CardHeader, + Chip, + Divider, + Skeleton, + Stack, + Typography, +} from "@mui/material"; +import { useForm } from "react-hook-form"; +import { Grid } from "@mui/system"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { CippApiResults } from "../CippComponents/CippApiResults"; + +const statusLabels = { + none: { label: "Not Configured", color: "default" }, + app_created: { label: "App Created", color: "info" }, + appid_stored: { label: "App ID Stored", color: "info" }, + secrets_stored: { label: "Secrets Stored", color: "success" }, + complete: { label: "Complete", color: "success" }, + error: { label: "Error", color: "error" }, +}; + +export const CippSSOSettings = () => { + const [showCreate, setShowCreate] = useState(false); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { multiTenant: false }, + }); + + const ssoStatus = ApiGetCall({ + url: "/api/ExecSSOSetup", + data: { Action: "Status" }, + queryKey: "SSOStatus", + }); + + const ssoAction = ApiPostCall({ + relatedQueryKeys: ["SSOStatus", "authmecipp"], + }); + + useEffect(() => { + if (ssoStatus.isSuccess && ssoStatus.data?.Results) { + const data = ssoStatus.data.Results; + formControl.reset({ multiTenant: data.multiTenant ?? false }); + setShowCreate(!data.configured); + } + }, [ssoStatus.isSuccess, ssoStatus.data]); + + const handleUpdate = () => { + if ( + !window.confirm( + "Updating SSO settings will restart the CIPP instance. Changes may take up to 60 seconds to reflect. Do you want to continue?" + ) + ) { + return; + } + ssoAction.mutate({ + url: "/api/ExecSSOSetup", + data: { + Action: "Update", + multiTenant: formControl.getValues("multiTenant"), + }, + }); + }; + + const handleCreate = () => { + ssoAction.mutate({ + url: "/api/ExecSSOSetup", + data: { + Action: "Create", + multiTenant: formControl.getValues("multiTenant"), + }, + }); + }; + + const handleRotateSecret = () => { + ssoAction.mutate({ + url: "/api/ExecSSOSetup", + data: { Action: "RotateSecret" }, + }); + }; + + const data = ssoStatus.data?.Results; + const statusInfo = statusLabels[data?.status] ?? statusLabels.none; + + return ( + + + {ssoStatus.isLoading ? ( + + + + + ) : ( + + + + + Status + + + + + + + {data?.appId && ( + <> + + + App ID + + + + + {data.appId} + + + + )} + + {data?.createdAt && ( + <> + + + Created + + + + + {new Date(data.createdAt).toLocaleString()} + + + + )} + + {data?.lastError && ( + <> + + + {data.lastError} + + + + )} + + + + + + + + + )} + + {!ssoStatus.isLoading && ( + + + {showCreate ? ( + + ) : ( + <> + + + + )} + + + )} + + ); +}; diff --git a/src/components/CippSettings/CippUserManagement.jsx b/src/components/CippSettings/CippUserManagement.jsx new file mode 100644 index 000000000000..b1d84c2f0b70 --- /dev/null +++ b/src/components/CippSettings/CippUserManagement.jsx @@ -0,0 +1,328 @@ +import React, { useState } from "react"; +import { + Alert, + Box, + Button, + CardActions, + CardContent, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import { TrashIcon, PlusIcon, PencilIcon } from "@heroicons/react/24/outline"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippApiResults } from "../CippComponents/CippApiResults"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; + +export const CippUserManagement = () => { + const [dialogOpen, setDialogOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [bulkEditUsers, setBulkEditUsers] = useState(null); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { UPN: "", Roles: [] }, + }); + + const usersQuery = ApiGetCall({ + url: "/api/ListCIPPUsers", + queryKey: "cippUsersList", + }); + + const userAction = ApiPostCall({ + relatedQueryKeys: ["cippUsersList"], + }); + + const pageData = usersQuery.data?.pages?.[0] ?? usersQuery.data ?? {}; + const allRoles = Array.isArray(pageData.AvailableRoles) ? pageData.AvailableRoles : []; + const roleOptions = allRoles.map((r) => ({ + label: `${r.RoleName} (${r.Type})`, + value: r.RoleName, + })); + const existingUsers = Array.isArray(pageData.Users) ? pageData.Users : []; + const existingUpns = new Set(existingUsers.map((u) => u.UPN.toLowerCase())); + + const openAddDialog = () => { + setEditingUser(null); + formControl.reset({ UPN: "", Roles: [] }); + setDialogOpen(true); + }; + + const openEditDialog = (row) => { + setEditingUser(row); + // Show only manual roles for editing — auto roles are managed by sync + const editableRoles = (row.ManualRoles ?? row.Roles ?? []).map((r) => { + const match = roleOptions.find((opt) => opt.value === r); + return match ?? { label: r, value: r }; + }); + formControl.reset({ UPN: row.UPN, Roles: editableRoles }); + setDialogOpen(true); + }; + + const handleSaveUser = (data) => { + const roles = Array.isArray(data.Roles) ? data.Roles.map((r) => r.value ?? r) : [data.Roles]; + + // Bulk edit: apply same roles to all selected users + const upns = bulkEditUsers ? bulkEditUsers.map((u) => u.UPN) : [data.UPN]; + + upns.forEach((upn) => { + userAction.mutate({ + url: "/api/ExecCIPPUsers", + data: { + Action: "AddUpdate", + UPN: upn, + Roles: roles, + }, + }); + }); + + formControl.reset({ UPN: "", Roles: [] }); + setEditingUser(null); + setBulkEditUsers(null); + setDialogOpen(false); + }; + + const actions = [ + { + label: "Edit Roles", + icon: ( + + + + ), + noConfirm: true, + customFunction: (row) => openEditDialog(row), + customBulkHandler: ({ data, clearSelection }) => { + setBulkEditUsers(data); + setEditingUser(null); + formControl.reset({ UPN: "", Roles: [] }); + setDialogOpen(true); + }, + }, + { + label: "Delete User", + icon: ( + + + + ), + confirmText: "Are you sure you want to remove this user's access to CIPP?", + url: "/api/ExecCIPPUsers", + type: "POST", + data: { + Action: "Delete", + UPN: "UPN", + }, + relatedQueryKeys: ["cippUsersList"], + }, + ]; + + const sourceLabel = (source) => { + switch (source) { + case "Auto": + return "Auto-synced from Entra groups"; + case "Both": + return "Auto-synced + Manual"; + case "Manual": + return "Manually assigned"; + default: + return source || "—"; + } + }; + + const offCanvas = { + children: (row) => ( + + + + Email / UPN + + {row.UPN} + + + + + Source + + {sourceLabel(row.Source)} + + + + + Effective Roles + + + {(row.Roles ?? []).map((role, idx) => ( + + ))} + + + {(row.ManualRoles ?? []).length > 0 && ( + <> + + + + Manual Roles + + + {row.ManualRoles.map((role, idx) => ( + + ))} + + + + )} + {(row.AutoRoles ?? []).length > 0 && ( + <> + + + + Auto Roles (from Entra groups) + + + {row.AutoRoles.map((role, idx) => ( + + ))} + + + + )} + {row.LastSync && ( + <> + + + + Last Synced + + + {new Date(row.LastSync).toLocaleString()} + + + + )} + + ), + }; + + return ( + + + +
    + } + onClick={openAddDialog} + > + Add User + + } + api={{ + url: "/api/ListCIPPUsers", + dataKey: "Users", + }} + queryKey="cippUsersList" + simpleColumns={["UPN", "Roles", "Source"]} + offCanvas={offCanvas} + /> + + + + { + setDialogOpen(false); + setBulkEditUsers(null); + }} + maxWidth="sm" + fullWidth + > + + {bulkEditUsers + ? `Bulk Edit Roles — ${bulkEditUsers.length} users` + : editingUser + ? `Edit Roles — ${editingUser.UPN}` + : "Add CIPP User"} + + + + + {bulkEditUsers + ? `Set the manual roles for ${bulkEditUsers.length} selected users. This will replace their existing manual roles. Auto-synced roles from Entra groups will not be affected.` + : editingUser + ? "Update the manually assigned roles for this user. Auto-synced roles from Entra groups are managed separately and will not be affected." + : "Add a user by their email address (UPN) and assign one or more roles. These are stored as manual assignments and won't be overwritten by the automatic Entra group sync."} + + {bulkEditUsers && ( + + + Selected Users + + + {bulkEditUsers.map((u, idx) => ( + + ))} + + + )} + {!editingUser && !bulkEditUsers && ( + { + if (existingUpns.has(value?.trim()?.toLowerCase())) { + return "This user already exists. Use Edit Roles to update their permissions."; + } + return true; + }, + }} + /> + )} + + + + + + + + + + ); +}; + +export default CippUserManagement; diff --git a/src/components/CippSettings/CippVersionProperties.jsx b/src/components/CippSettings/CippVersionProperties.jsx index 4b19e1d9e328..97542aa3ebbc 100644 --- a/src/components/CippSettings/CippVersionProperties.jsx +++ b/src/components/CippSettings/CippVersionProperties.jsx @@ -11,7 +11,7 @@ const CippVersionProperties = () => { }); const cippVersion = ApiGetCall({ - url: `/api/GetVersion?LocalVersion=${version?.data?.version}`, + url: `/api/GetVersion?LocalVersion=${encodeURIComponent(version?.data?.version ?? "")}`, queryKey: "CippVersion", waiting: false, }); diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index ee2d86b3f78c..931a5c2cc271 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useMemo } from "react"; import { Card, Stack, + Alert, Avatar, Box, Typography, @@ -27,11 +28,12 @@ import { NotificationImportant, Assignment, Construction, + Warning, } from "@mui/icons-material"; import { Grid } from "@mui/system"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { useWatch, useFormState } from "react-hook-form"; -import _ from "lodash"; +import { get, isEqual, cloneDeep } from "lodash"; import Microsoft from "../../icons/iconly/bulk/microsoft"; import Azure from "../../icons/iconly/bulk/azure"; import Exchange from "../../icons/iconly/bulk/exchange"; @@ -53,8 +55,9 @@ const getAvailableActions = (disabledFeatures) => { return allActions.filter((action) => !disabledFeatures?.[action.value.toLowerCase()]); }; -const CippAddedComponent = React.memo(({ standardName, component, formControl }) => { +const CippAddedComponent = React.memo(({ standardName, component, formControl, currentValue }) => { const updatedComponent = { ...component }; + const fieldName = `${standardName}.${updatedComponent.name}`; if (component.type === "AdminRolesMultiSelect") { updatedComponent.type = "autoComplete"; @@ -73,15 +76,30 @@ const CippAddedComponent = React.memo(({ standardName, component, formControl }) updatedComponent.type = component.type; } + const warningThreshold = Number(updatedComponent.warningThreshold); + const numericValue = Number(currentValue); + const showThresholdWarning = + Number.isFinite(warningThreshold) && + !Number.isNaN(numericValue) && + `${currentValue}`.trim() !== "" && + numericValue > warningThreshold; + + const warningMessage = + updatedComponent.warningMessage || + `Values above ${warningThreshold} can match unrelated policies. Use with caution.`; + return ( - + + + {showThresholdWarning && {warningMessage}} + ); }); @@ -151,7 +169,7 @@ const CippStandardAccordion = ({ // ALWAYS require an action for any standard to be considered configured // The action field should be an array with at least one element - const actionValue = _.get(values, "action"); + const actionValue = get(values, "action"); if (!actionValue || (Array.isArray(actionValue) && actionValue.length === 0)) return false; // Additional checks for required components @@ -171,7 +189,7 @@ const CippStandardAccordion = ({ // Handle conditional fields if (component.condition) { const conditionField = component.condition.field; - const conditionValue = _.get(values, conditionField); + const conditionValue = get(values, conditionField); const compareType = component.condition.compareType || "is"; const compareValue = component.condition.compareValue; const propertyName = component.condition.propertyName || "value"; @@ -180,10 +198,10 @@ const CippStandardAccordion = ({ if (propertyName === "value") { switch (compareType) { case "is": - conditionMet = _.isEqual(conditionValue, compareValue); + conditionMet = isEqual(conditionValue, compareValue); break; case "isNot": - conditionMet = !_.isEqual(conditionValue, compareValue); + conditionMet = !isEqual(conditionValue, compareValue); break; default: conditionMet = false; @@ -207,7 +225,7 @@ const CippStandardAccordion = ({ if (!isRequired) return true; // Get field value using lodash's get to properly handle nested properties - const fieldValue = _.get(values, component.name); + const fieldValue = get(values, component.name); // Check if field has a value based on its type and multiple property if (component.type === "autoComplete" || component.type === "select") { @@ -246,10 +264,10 @@ const CippStandardAccordion = ({ // For each standard, get its current values and determine if it's configured Object.keys(selectedStandards).forEach((standardName) => { - const currentValues = _.get(watchedValues, standardName); + const currentValues = get(watchedValues, standardName); if (!currentValues) return; - initial[standardName] = _.cloneDeep(currentValues); + initial[standardName] = cloneDeep(currentValues); const baseStandardName = standardName.split("[")[0]; const standard = providedStandards.find((s) => s.name === baseStandardName); @@ -288,9 +306,9 @@ const CippStandardAccordion = ({ const updated = { ...prev }; removedKeys.forEach((k) => delete updated[k]); addedKeys.forEach((k) => { - const currentValues = _.get(watchedValues, k); + const currentValues = get(watchedValues, k); if (currentValues) { - updated[k] = _.cloneDeep(currentValues); + updated[k] = cloneDeep(currentValues); } }); return updated; @@ -302,7 +320,7 @@ const CippStandardAccordion = ({ addedKeys.forEach((k) => { const baseStandardName = k.split("[")[0]; const standard = providedStandards.find((s) => s.name === baseStandardName); - const currentValues = _.get(watchedValues, k); + const currentValues = get(watchedValues, k); if (standard && currentValues) { updated[k] = isStandardConfigured(k, standard, currentValues); } @@ -316,7 +334,7 @@ const CippStandardAccordion = ({ // Save changes for a standard const handleSave = (standardName, standard, current) => { // Clone the current values to avoid reference issues - const newValues = _.cloneDeep(current); + const newValues = cloneDeep(current); // Update saved values setSavedValues((prev) => ({ @@ -352,11 +370,11 @@ const CippStandardAccordion = ({ // Cancel changes for a standard const handleCancel = (standardName) => { // Get the last saved values - const savedValue = _.get(savedValues, standardName); + const savedValue = get(savedValues, standardName); if (!savedValue) return; // Set the entire standard's value at once to ensure proper handling of nested objects and arrays - formControl.setValue(standardName, _.cloneDeep(savedValue)); + formControl.setValue(standardName, cloneDeep(savedValue)); // Find the original standard definition to get the base standard const baseStandardName = standardName.split("[")[0]; @@ -384,7 +402,23 @@ const CippStandardAccordion = ({ Object.keys(selectedStandards).forEach((standardName) => { const baseStandardName = standardName.split("[")[0]; const standard = providedStandards.find((s) => s.name === baseStandardName); - if (!standard) return; + + if (!standard) { + // Unknown/deprecated standard — surface it so the user can remove it + const unknownCategory = "Unknown Standards"; + if (!result[unknownCategory]) { + result[unknownCategory] = []; + } + result[unknownCategory].push({ + standardName, + standard: { + _unknown: true, + name: baseStandardName, + label: baseStandardName, + }, + }); + return; + } const standardInfo = standards.find((s) => s.name === baseStandardName); const category = standardInfo?.cat || "Other Standards"; @@ -437,7 +471,7 @@ const CippStandardAccordion = ({ Array.isArray(standard.appliesToTest) && standard.appliesToTest.some((testId) => testId.toLowerCase().includes(searchLower))); - const isConfigured = _.get(configuredState, standardName); + const isConfigured = get(configuredState, standardName); const matchesFilter = filter === "all" || (filter === "configured" && isConfigured) || @@ -596,13 +630,76 @@ const CippStandardAccordion = ({ {filteredGroupedStandards[category].map(({ standardName, standard }) => { + if (standard._unknown) { + const isExpanded = expanded === standardName; + const rawData = get(watchedValues, standardName); + return ( + + + + + + + + {standard.label} + + This standard no longer exists and should be removed. + + + + + + handleRemoveStandard(standardName)}> + + + + handleAccordionToggle(standardName)}> + + + + + + + + + Stored Configuration + + + {JSON.stringify(rawData, null, 2)} + + + + + ); + } + const isExpanded = expanded === standardName; const hasAddedComponents = standard.addedComponent && standard.addedComponent.length > 0; - const isConfigured = _.get(configuredState, standardName); + const isConfigured = get(configuredState, standardName); const disabledFeatures = standard.disabledFeatures || {}; - let selectedActions = _.get(watchedValues, `${standardName}.action`); + let selectedActions = get(watchedValues, `${standardName}.action`); if (selectedActions && !Array.isArray(selectedActions)) { selectedActions = [selectedActions]; } @@ -611,13 +708,13 @@ const CippStandardAccordion = ({ let templateDisplayName = ""; if (standardName.startsWith("standards.IntuneTemplate")) { // Check for TemplateList selection - const templateList = _.get(watchedValues, `${standardName}.TemplateList`); + const templateList = get(watchedValues, `${standardName}.TemplateList`); if (templateList && templateList.label) { templateDisplayName = templateList.label; } // Check for TemplateList-Tags selection (takes priority) - const templateListTags = _.get(watchedValues, `${standardName}.TemplateList-Tags`); + const templateListTags = get(watchedValues, `${standardName}.TemplateList-Tags`); if (templateListTags && templateListTags.label) { templateDisplayName = templateListTags.label; } @@ -625,21 +722,21 @@ const CippStandardAccordion = ({ // For multiple standards, check the first added component const selectedTemplateName = standard.multiple - ? _.get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) + ? get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) : ""; // Build accordion title with template name if available const accordionTitle = templateDisplayName ? `${standard.label} - ${templateDisplayName}` - : selectedTemplateName && _.get(selectedTemplateName, "label") - ? `${standard.label} - ${_.get(selectedTemplateName, "label")}` + : selectedTemplateName && get(selectedTemplateName, "label") + ? `${standard.label} - ${get(selectedTemplateName, "label")}` : standard.label; // Get current values and check if they differ from saved values - const current = _.get(watchedValues, standardName); - const saved = _.get(savedValues, standardName) || {}; + const current = get(watchedValues, standardName); + const saved = get(savedValues, standardName) || {}; - const hasUnsaved = !_.isEqual(current, saved); + const hasUnsaved = !isEqual(current, saved); // Check if all required fields are filled const requiredFieldsFilled = current @@ -654,7 +751,7 @@ const CippStandardAccordion = ({ // Handle conditional fields if (component.condition) { const conditionField = component.condition.field; - const conditionValue = _.get(current, conditionField); + const conditionValue = get(current, conditionField); const compareType = component.condition.compareType || "is"; const compareValue = component.condition.compareValue; const propertyName = component.condition.propertyName || "value"; @@ -663,10 +760,10 @@ const CippStandardAccordion = ({ if (propertyName === "value") { switch (compareType) { case "is": - conditionMet = _.isEqual(conditionValue, compareValue); + conditionMet = isEqual(conditionValue, compareValue); break; case "isNot": - conditionMet = !_.isEqual(conditionValue, compareValue); + conditionMet = !isEqual(conditionValue, compareValue); break; default: conditionMet = false; @@ -688,7 +785,7 @@ const CippStandardAccordion = ({ } // Get field value for validation using lodash's get to properly handle nested properties - const fieldValue = _.get(current, component.name); + const fieldValue = get(current, component.name); // Check if required field has a value based on its type and multiple property if (component.type === "autoComplete" || component.type === "select") { @@ -717,12 +814,12 @@ const CippStandardAccordion = ({ ); // Action is always required and must be an array with at least one element - const actionValue = _.get(current, "action"); + const actionValue = get(current, "action"); const hasAction = actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); // Check if this standard has any validation errors - const standardErrors = _.get(formErrors, standardName); + const standardErrors = get(formErrors, standardName); const hasValidationErrors = standardErrors && Object.keys(standardErrors).length > 0; // Allow saving if: @@ -940,6 +1037,10 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} + currentValue={get( + watchedValues, + `${standardName}.${component.name}`, + )} /> ) : ( @@ -948,6 +1049,10 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} + currentValue={get( + watchedValues, + `${standardName}.${component.name}`, + )} /> ), )} @@ -998,6 +1103,10 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} + currentValue={get( + watchedValues, + `${standardName}.${component.name}`, + )} /> ) : ( @@ -1006,6 +1115,10 @@ const CippStandardAccordion = ({ standardName={standardName} component={component} formControl={formControl} + currentValue={get( + watchedValues, + `${standardName}.${component.name}`, + )} /> ), )} diff --git a/src/components/CippStandards/CippStandardsSideBar.jsx b/src/components/CippStandards/CippStandardsSideBar.jsx index f633f9b14ed9..2cab69c42f7e 100644 --- a/src/components/CippStandards/CippStandardsSideBar.jsx +++ b/src/components/CippStandards/CippStandardsSideBar.jsx @@ -29,7 +29,7 @@ import CheckIcon from "@heroicons/react/24/outline/CheckIcon"; import CloseIcon from "@mui/icons-material/Close"; import { useWatch } from "react-hook-form"; import { useEffect, useState } from "react"; -import _ from "lodash"; +import { get } from "lodash"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { CippFormTenantSelector } from "../CippComponents/CippFormTenantSelector"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; @@ -241,14 +241,14 @@ const CippStandardsSideBar = ({ useEffect(() => { const stepsStatus = { - step1: !!_.get(watchForm, "templateName"), - step2: _.get(watchForm, "tenantFilter", []).length > 0, + step1: !!get(watchForm, "templateName"), + step2: get(watchForm, "tenantFilter", []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - _.get(watchForm, "standards") && + get(watchForm, "standards") && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, `${standardName}`, {}); + const standardValues = get(watchForm, `${standardName}`, {}); const standard = selectedStandards[standardName]; // Check if this standard requires an action const hasRequiredComponents = @@ -258,7 +258,7 @@ const CippStandardsSideBar = ({ ); const actionRequired = standard?.disabledFeatures !== undefined || hasRequiredComponents; // Always require an action value which should be an array with at least one element - const actionValue = _.get(standardValues, "action"); + const actionValue = get(standardValues, "action"); return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); }), }; @@ -269,17 +269,17 @@ const CippStandardsSideBar = ({ // Create a local reference to the stepsStatus from the latest effect run const stepsStatus = { - step1: !!_.get(watchForm, "templateName"), - step2: _.get(watchForm, "tenantFilter", []).length > 0, + step1: !!get(watchForm, "templateName"), + step2: get(watchForm, "tenantFilter", []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - _.get(watchForm, "standards") && + get(watchForm, "standards") && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, `${standardName}`, {}); + const standardValues = get(watchForm, `${standardName}`, {}); const standard = selectedStandards[standardName]; // Always require an action for all standards (must be an array with at least one element) - const actionValue = _.get(standardValues, "action"); + const actionValue = get(standardValues, "action"); return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); }), }; diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 0fc4f87432b7..22c9bfe66ff2 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -341,6 +341,7 @@ export const CippDataTable = (props) => { }, exportEnabled = true, simpleColumns = [], + dataFilter, actions, title = 'Report', simple = false, @@ -476,7 +477,7 @@ export const CippDataTable = (props) => { const nestedData = getNestedValue(page, api.dataKey) return nestedData !== undefined ? nestedData : [] }) - setUsedData(combinedResults) + setUsedData(dataFilter ? combinedResults.filter(dataFilter) : combinedResults) } }, [ getRequestData.isSuccess, @@ -579,6 +580,9 @@ export const CippDataTable = (props) => { }, [columns.length, usedData, queryKey, settings?.currentTenant, filterTypeMap]) const createDialog = useDialog() + const hasActions = !!actions + const hasOffCanvas = !!offCanvas + const hasOnChange = !!onChange // Compute modeInfo via useMemo so it stays stable but updates when relevant inputs change. const modeInfo = useMemo( @@ -593,7 +597,7 @@ export const CippDataTable = (props) => { maxHeightOffset, settings ), - [simple, !!actions, !!offCanvas, !!onChange, maxHeightOffset, settings?.tablePageSize?.value] + [simple, hasActions, hasOffCanvas, hasOnChange, maxHeightOffset, settings?.tablePageSize?.value] ) // Include updateTrigger in data memo to force re-render when license backfill completes @@ -651,7 +655,15 @@ export const CippDataTable = (props) => { const muiTableBodyRowProps = useMemo(() => { if (offCanvasOnRowClick && offCanvas) { return ({ row }) => ({ - onClick: () => { + onClick: (event) => { + if ( + event.target?.closest?.( + 'button, a, input, textarea, select, [role="button"], [role="menuitem"], [data-no-row-click="true"]' + ) + ) { + return + } + setOffCanvasData(row.original) const filteredRowsArray = table?.getFilteredRowModel?.()?.rows if (filteredRowsArray) { diff --git a/src/components/CippTable/CippDataTableButton.jsx b/src/components/CippTable/CippDataTableButton.jsx index 79eec0f04bc5..86c3c887e1e9 100644 --- a/src/components/CippTable/CippDataTableButton.jsx +++ b/src/components/CippTable/CippDataTableButton.jsx @@ -5,7 +5,9 @@ import { getCippTranslation } from "../../utils/get-cipp-translation"; const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { const [openDialogs, setOpenDialogs] = useState([]); - const handleOpenDialog = () => { + const handleOpenDialog = (event) => { + event?.stopPropagation(); + let dataArray; if (Array.isArray(data)) { @@ -21,7 +23,8 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { setOpenDialogs([...openDialogs, dataArray]); }; - const handleCloseDialog = (index) => { + const handleCloseDialog = (index, event) => { + event?.stopPropagation?.(); setOpenDialogs(openDialogs.filter((_, i) => i !== index)); }; const dataIsNotANullArray = @@ -48,7 +51,9 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { handleCloseDialog(index)} + onClose={(event) => handleCloseDialog(index, event)} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} fullWidth maxWidth="lg" > diff --git a/src/components/CippWizard/CippIntunePolicy.jsx b/src/components/CippWizard/CippIntunePolicy.jsx index 455c14adf751..10d46a1b7e83 100644 --- a/src/components/CippWizard/CippIntunePolicy.jsx +++ b/src/components/CippWizard/CippIntunePolicy.jsx @@ -1,63 +1,63 @@ -import { Stack } from "@mui/material"; -import { Grid } from "@mui/system"; -import { CippWizardStepButtons } from "./CippWizardStepButtons"; -import CippJsonView from "../CippFormPages/CippJSONView"; -import CippFormComponent from "../CippComponents/CippFormComponent"; -import { ApiGetCall } from "../../api/ApiCall"; -import { useEffect, useState } from "react"; -import { useWatch } from "react-hook-form"; -import { CippFormCondition } from "../CippComponents/CippFormCondition"; -import { useSettings } from "../../hooks/use-settings"; +import { Stack } from '@mui/material' +import { Grid } from '@mui/system' +import { CippWizardStepButtons } from './CippWizardStepButtons' +import CippJsonView from '../CippFormPages/CippJSONView' +import CippFormComponent from '../CippComponents/CippFormComponent' +import { ApiGetCall } from '../../api/ApiCall' +import { useEffect, useState } from 'react' +import { useWatch } from 'react-hook-form' +import { CippFormCondition } from '../CippComponents/CippFormCondition' +import { useSettings } from '../../hooks/use-settings' const assignmentFilterTypeOptions = [ - { label: "Include - Apply policy to devices matching filter", value: "include" }, - { label: "Exclude - Apply policy to devices NOT matching filter", value: "exclude" }, -]; + { label: 'Include - Apply policy to devices matching filter', value: 'include' }, + { label: 'Exclude - Apply policy to devices NOT matching filter', value: 'exclude' }, +] export const CippIntunePolicy = (props) => { - const { formControl, onPreviousStep, onNextStep, currentStep } = props; - const values = formControl.getValues(); - const tenantFilter = useSettings()?.currentTenant; - const CATemplates = ApiGetCall({ url: "/api/ListIntuneTemplates", queryKey: "IntuneTemplates" }); - const [JSONData, setJSONData] = useState(); - const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); - const jsonWatch = useWatch({ control: formControl.control, name: "RAWJson" }); - const selectedTenants = useWatch({ control: formControl.control, name: "tenantFilter" }); + const { formControl, onPreviousStep, onNextStep, currentStep } = props + const values = formControl.getValues() + const tenantFilter = useSettings()?.currentTenant + const CATemplates = ApiGetCall({ url: '/api/ListIntuneTemplates', queryKey: 'IntuneTemplates' }) + const [JSONData, setJSONData] = useState() + const watcher = useWatch({ control: formControl.control, name: 'TemplateList' }) + const jsonWatch = useWatch({ control: formControl.control, name: 'RAWJson' }) + const selectedTenants = useWatch({ control: formControl.control, name: 'tenantFilter' }) // do not provide inputs for reserved placeholders const reservedPlaceholders = [ - "%serial%", - "%systemroot%", - "%systemdrive%", - "%temp%", - "%tenantid%", - "%tenantfilter%", - "%initialdomain%", - "%tenantname%", - "%partnertenantid%", - "%samappid%", - "%userprofile%", - "%username%", - "%userdomain%", - "%windir%", - "%programfiles%", - "%programfiles(x86)%", - "%programdata%", - ]; + '%serial%', + '%systemroot%', + '%systemdrive%', + '%temp%', + '%tenantid%', + '%tenantfilter%', + '%initialdomain%', + '%tenantname%', + '%partnertenantid%', + '%samappid%', + '%userprofile%', + '%username%', + '%userdomain%', + '%windir%', + '%programfiles%', + '%programfiles(x86)%', + '%programdata%', + ] useEffect(() => { if (CATemplates.isSuccess && watcher?.value) { - const template = CATemplates.data.find((template) => template.GUID === watcher.value); + const template = CATemplates.data.find((template) => template.GUID === watcher.value) if (template) { - const jsonTemplate = template.RAWJson ? JSON.parse(template.RAWJson) : null; - setJSONData(jsonTemplate); - formControl.setValue("RAWJson", template.RAWJson); - formControl.setValue("displayName", template.Displayname); - formControl.setValue("description", template.Description); - formControl.setValue("TemplateType", template.Type); + const jsonTemplate = template.RAWJson ? JSON.parse(template.RAWJson) : null + setJSONData(jsonTemplate) + formControl.setValue('RAWJson', template.RAWJson) + formControl.setValue('displayName', template.Displayname) + formControl.setValue('description', template.Description) + formControl.setValue('TemplateType', template.Type) } } - }, [watcher]); + }, [watcher]) return ( @@ -93,11 +93,11 @@ export const CippIntunePolicy = (props) => { type="radio" name="AssignTo" options={[ - { label: "Do not assign", value: "On" }, - { label: "Assign to all users", value: "allLicensedUsers" }, - { label: "Assign to all devices", value: "AllDevices" }, - { label: "Assign to all users and devices", value: "AllDevicesAndUsers" }, - { label: "Assign to Custom Group", value: "customGroup" }, + { label: 'Do not assign', value: 'On' }, + { label: 'Assign to all users', value: 'allLicensedUsers' }, + { label: 'Assign to all devices', value: 'AllDevices' }, + { label: 'Assign to all users and devices', value: 'AllDevicesAndUsers' }, + { label: 'Assign to Custom Group', value: 'customGroup' }, ]} formControl={formControl} /> @@ -114,7 +114,22 @@ export const CippIntunePolicy = (props) => { label="Custom Group Names separated by comma. Wildcards (*) are allowed" name="customGroup" formControl={formControl} - validators={{ required: "Please specify custom group names" }} + validators={{ required: 'Please specify custom group names' }} + /> + + + + + @@ -122,7 +137,7 @@ export const CippIntunePolicy = (props) => { formControl={formControl} field="AssignTo" compareType="isOneOf" - compareValue={["allLicensedUsers", "AllDevices", "AllDevicesAndUsers", "customGroup"]} + compareValue={['allLicensedUsers', 'AllDevices', 'AllDevicesAndUsers', 'customGroup']} > { creatable={false} formControl={formControl} api={{ - url: "/api/ListAssignmentFilters", + url: '/api/ListAssignmentFilters', queryKey: `ListAssignmentFilters-${tenantFilter}`, labelField: (filter) => filter.displayName, - valueField: "displayName", + valueField: 'displayName', }} /> @@ -159,15 +174,15 @@ export const CippIntunePolicy = (props) => { compareValue={/%(\w+)%/} > {(() => { - const rawJson = jsonWatch ? jsonWatch : ""; - const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1]); - const uniquePlaceholders = Array.from(new Set(placeholderMatches)); + const rawJson = jsonWatch ? jsonWatch : '' + const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1]) + const uniquePlaceholders = Array.from(new Set(placeholderMatches)) // Filter out reserved placeholders const filteredPlaceholders = uniquePlaceholders.filter( (placeholder) => !reservedPlaceholders.includes(`%${placeholder.toLowerCase()}%`) - ); + ) if (filteredPlaceholders.length === 0 || selectedTenants.length === 0) { - return null; + return null } return filteredPlaceholders.map((placeholder) => ( @@ -177,11 +192,11 @@ export const CippIntunePolicy = (props) => { type="textField" defaultValue={ //if the placeholder is tenantid then replace it with tenant.addedFields.customerId, if the placeholder is tenantdomain then replace it with tenant.addedFields.defaultDomainName. - placeholder === "tenantid" + placeholder === 'tenantid' ? tenant?.addedFields?.customerId - : placeholder === "tenantdomain" - ? tenant?.addedFields?.defaultDomainName - : "" + : placeholder === 'tenantdomain' + ? tenant?.addedFields?.defaultDomainName + : '' } name={`replacemap.${tenant.value}.%${placeholder}%`} label={`Value for '${placeholder}' in Tenant '${tenant.addedFields.defaultDomainName}'`} @@ -190,7 +205,7 @@ export const CippIntunePolicy = (props) => { /> ))} - )); + )) })()} @@ -198,10 +213,10 @@ export const CippIntunePolicy = (props) => { currentStep={currentStep} onPreviousStep={onPreviousStep} onNextStep={onNextStep} - noNextButton={values.selectedOption === "UpdateTokens"} + noNextButton={values.selectedOption === 'UpdateTokens'} formControl={formControl} noSubmitButton={true} /> - ); -}; + ) +} diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx index c14bb0aa573b..c1f5b2669c43 100644 --- a/src/components/CippWizard/CippTenantModeDeploy.jsx +++ b/src/components/CippWizard/CippTenantModeDeploy.jsx @@ -47,6 +47,13 @@ export const CippTenantModeDeploy = (props) => { } }, [updateRefreshToken.isSuccess, formControl, addTenant.isSuccess]); + useEffect(() => { + if (partnerTenantInfo?.data?.authenticatedUserPrincipalName) { + formControl.setValue("GDAPAuth", true); + formControl.trigger("GDAPAuth"); + } + }, [partnerTenantInfo?.data?.authenticatedUserPrincipalName, formControl]); + return ( {/* Partner Tenant (GDAP) */} diff --git a/src/components/CippWizard/CippWizardGroupTemplates.jsx b/src/components/CippWizard/CippWizardGroupTemplates.jsx index 2e1679808723..445043dff727 100644 --- a/src/components/CippWizard/CippWizardGroupTemplates.jsx +++ b/src/components/CippWizard/CippWizardGroupTemplates.jsx @@ -41,6 +41,9 @@ export const CippWizardGroupTemplates = (props) => { formControl.setValue("membershipRules", watcher.addedFields.membershipRules, { shouldValidate: true, }); + formControl.setValue("licenses", watcher.addedFields.licenses || [], { + shouldValidate: true, + }); console.log("Set membershipRules to:", watcher.addedFields.membershipRules); }, 100); @@ -71,6 +74,7 @@ export const CippWizardGroupTemplates = (props) => { username: "username", allowExternal: "allowExternal", membershipRules: "membershipRules", + licenses: "licenses", }, showRefresh: true, }} diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx index d1a651e5c3a5..990cb9d35b11 100644 --- a/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/src/components/CippWizard/CippWizardOffboarding.jsx @@ -205,6 +205,13 @@ export const CippWizardOffboarding = (props) => { formControl={formControl} disabled={!!deleteUser} /> + { /> {deleteUser && ( - When a user is deleted, their OneDrive is retained for 30 days by default unless otherwise configured. + When a user is deleted, their OneDrive is retained for 30 days by default unless + otherwise configured. )} { }, }} /> - Email Forwarding diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index 5b067cf4e7c3..15b438b2c608 100644 --- a/src/components/PrivateRoute.js +++ b/src/components/PrivateRoute.js @@ -2,8 +2,11 @@ import { ApiGetCall } from "../api/ApiCall.jsx"; import UnauthenticatedPage from "../pages/unauthenticated.js"; import LoadingPage from "../pages/loading.js"; import ApiOfflinePage from "../pages/api-offline.js"; +import { useState, useEffect } from "react"; export const PrivateRoute = ({ children, routeType }) => { + const [unauthLatched, setUnauthLatched] = useState(false); + const session = ApiGetCall({ url: "/.auth/me", queryKey: "authmeswa", @@ -11,13 +14,34 @@ export const PrivateRoute = ({ children, routeType }) => { staleTime: 120000, // 2 minutes }); + // Latch the unauthenticated state so refetches from child components + // don't flip us back to loading. Clear the latch when session succeeds (after login). + useEffect(() => { + if ( + !session.isLoading && + !session.isFetching && + (session.isError || + null === session?.data?.clientPrincipal || + session?.data === undefined) + ) { + setUnauthLatched(true); + } else if (session.isSuccess && session.data?.clientPrincipal) { + setUnauthLatched(false); + } + }, [session.isLoading, session.isFetching, session.isError, session.isSuccess, session.data]); + const apiRoles = ApiGetCall({ url: "/api/me", queryKey: "authmecipp", - retry: 2, // Reduced retry count to show offline message sooner - waiting: !session.isSuccess || session.data?.clientPrincipal === null, + retry: 2, + waiting: session.isSuccess && session.data?.clientPrincipal !== null, }); + // If latched as unauthenticated, always show unauthenticated page + if (unauthLatched) { + return ; + } + // Check if the session is still loading before determining authentication status if ( session.isLoading || @@ -38,11 +62,6 @@ export const PrivateRoute = ({ children, routeType }) => { return ; } - // if not logged into swa - if (null === session?.data?.clientPrincipal || session?.data === undefined) { - return ; - } - let roles = null; if ( diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index 35434ed81fd6..5b99535d1c36 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -31,6 +31,7 @@ import { GitHub } from '@mui/icons-material' import { CippAutoComplete } from './CippComponents/CippAutocomplete' const RELEASE_COOKIE_KEY = 'cipp_release_notice' +const RELEASE_PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden' const RELEASE_OWNER = 'KelvinTegelaar' const RELEASE_REPO = 'CIPP' @@ -70,6 +71,14 @@ const setCookie = (name, value, days = 365) => { )}; expires=${expires}; path=/; SameSite=Lax;${secureFlag()}` } +const deleteCookie = (name) => { + if (typeof document === 'undefined') { + return + } + + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax;${secureFlag()}` +} + const buildReleaseMetadata = (version) => { const [major = '0', minor = '0', patch = '0'] = String(version).split('.') const currentTag = `v${major}.${minor}.${patch}` @@ -142,8 +151,15 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { } const storedValue = getCookie(RELEASE_COOKIE_KEY) + if (storedValue === 'permanently_dismissed') { + window.localStorage.setItem(RELEASE_PERMANENT_HIDE_KEY, 'true') + deleteCookie(RELEASE_COOKIE_KEY) + return + } + + const permanentlyHidden = window.localStorage.getItem(RELEASE_PERMANENT_HIDE_KEY) === 'true' - if (storedValue !== releaseMeta.releaseTag) { + if (!permanentlyHidden && storedValue !== releaseMeta.releaseTag) { setIsEligible(true) } }, [releaseMeta.releaseTag]) @@ -262,6 +278,7 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const handleDismissUntilNextRelease = () => { const newestRelease = releaseCatalog[0] const tagToStore = newestRelease?.releaseTag ?? newestRelease?.tagName ?? releaseMeta.releaseTag + window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY) setCookie(RELEASE_COOKIE_KEY, tagToStore) setOpen(false) setIsExpanded(false) @@ -269,7 +286,17 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { setIsEligible(false) } + const handleDismissPermanently = () => { + window.localStorage.setItem(RELEASE_PERMANENT_HIDE_KEY, 'true') + deleteCookie(RELEASE_COOKIE_KEY) + setOpen(false) + setIsExpanded(false) + setManualOpenRequested(false) + setIsEligible(false) + } + const handleRemindLater = () => { + window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY) setOpen(false) setIsExpanded(false) setManualOpenRequested(false) @@ -457,7 +484,15 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { > View release notes on GitHub - + + diff --git a/src/components/bulk-actions-menu.js b/src/components/bulk-actions-menu.js index ff9a8613665a..18f1ad5af1c0 100644 --- a/src/components/bulk-actions-menu.js +++ b/src/components/bulk-actions-menu.js @@ -1,44 +1,12 @@ -import PropTypes from "prop-types"; -import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; -import { Button, Link, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material"; -import { usePopover } from "../hooks/use-popover"; -import { FilePresent, Laptop, Mail, Share, Shield, ShieldMoon, PrecisionManufacturing, BarChart, Group } from "@mui/icons-material"; -import { GlobeAltIcon, UsersIcon, ServerIcon } from "@heroicons/react/24/outline"; - -function getIconByName(iconName) { - switch (iconName) { - case "GlobeAltIcon": - return ; - case "Mail": - return ; - case "UsersIcon": - return ; - case "FilePresent": - return ; - case "ServerIcon": - return ; - case "Laptop": - return ; - case "Share": - return ; - case "Shield": - return ; - case "ShieldMoon": - return ; - case "PrecisionManufacturing": - return ; - case "BarChart": - return ; - case "Group": - return ; - default: - return null; - } -} +import PropTypes from 'prop-types' +import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon' +import { Button, Link, ListItemText, Menu, MenuItem, SvgIcon } from '@mui/material' +import { usePopover } from '../hooks/use-popover' +import { getIconByName } from '../utils/icon-registry' export const BulkActionsMenu = (props) => { - const { buttonName, sx, row, actions = [], ...other } = props; - const popover = usePopover(); + const { buttonName, sx, row, actions = [], ...other } = props + const popover = usePopover() return ( <> @@ -53,7 +21,7 @@ export const BulkActionsMenu = (props) => { variant="outlined" sx={{ flexShrink: 0, - whiteSpace: "nowrap", + whiteSpace: 'nowrap', ...sx, }} {...other} @@ -63,8 +31,8 @@ export const BulkActionsMenu = (props) => { { onClose={popover.handleClose} open={popover.open} transformOrigin={{ - horizontal: "right", - vertical: "top", + horizontal: 'right', + vertical: 'top', }} > {actions.map((action, index) => { + const icon = getIconByName(action.icon, { sx: { mr: 1 } }) + if (action.link) { return ( { target="_blank" rel="noreferrer" > - {getIconByName(action.icon)} + {icon} - ); + ) } else { return ( { if (action.onClick) { - action.onClick(); + action.onClick() } - popover.handleClose(); + popover.handleClose() }} > - {getIconByName(action.icon)} + {icon} - ); + ) } })} - ); -}; + ) +} BulkActionsMenu.propTypes = { onArchive: PropTypes.func, onDelete: PropTypes.func, selectedCount: PropTypes.number, -}; +} diff --git a/src/contexts/tutorial-context.js b/src/contexts/tutorial-context.js new file mode 100644 index 000000000000..b32ab50c8fa8 --- /dev/null +++ b/src/contexts/tutorial-context.js @@ -0,0 +1,177 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { driver } from 'driver.js' +import { useRouter } from 'next/router' + +const STORAGE_KEY = 'cipp.tutorials.completed' + +const getCompletedTutorials = () => { + try { + const stored = localStorage.getItem(STORAGE_KEY) + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } +} + +const storeCompletedTutorial = (id) => { + try { + const completed = getCompletedTutorials() + if (!completed.includes(id)) { + completed.push(id) + localStorage.setItem(STORAGE_KEY, JSON.stringify(completed)) + } + } catch { + // ignore + } +} + +const resetCompletedTutorials = () => { + try { + localStorage.removeItem(STORAGE_KEY) + } catch { + // ignore + } +} + +const TutorialContext = createContext({ + tutorials: [], + activeTutorial: null, + completedIds: [], + startTutorial: () => {}, + resetProgress: () => {}, + getTutorialsForPage: () => [], +}) + +// Load all tutorial JSON files from the data/tutorials folder at build time +const loadTutorials = () => { + const context = require.context('../data/tutorials', false, /\.json$/) + return context.keys().map((key) => { + const tutorial = context(key) + return tutorial.default || tutorial + }) +} + +export const TutorialProvider = ({ children }) => { + const [tutorials] = useState(() => loadTutorials()) + const [completedIds, setCompletedIds] = useState([]) + const [activeTutorial, setActiveTutorial] = useState(null) + const driverRef = useRef(null) + const router = useRouter() + + useEffect(() => { + setCompletedIds(getCompletedTutorials()) + }, []) + + // Launch tutorial from ?tutorial=$id query param + useEffect(() => { + if (!router.isReady || activeTutorial) return + const tutorialId = router.query.tutorial + if (!tutorialId) return + + const match = tutorials.find((t) => t.id === tutorialId) + if (!match) return + + // Strip the query param so it doesn't re-trigger + const { tutorial: _, ...rest } = router.query + + // If the tutorial has a target page and we're not on it, navigate there first + const targetPage = match.pages?.[0] + if (targetPage && router.pathname !== targetPage) { + router.replace({ pathname: targetPage, query: rest }, undefined).then(() => { + setTimeout(() => runDriver(match), 600) + }) + return + } + + router.replace({ pathname: router.pathname, query: rest }, undefined, { shallow: true }) + + // Delay to let the page fully render + setTimeout(() => runDriver(match), 600) + }, [router.isReady, router.query.tutorial, tutorials]) + + // Cleanup driver on unmount or route change + useEffect(() => { + return () => { + if (driverRef.current) { + driverRef.current.destroy() + driverRef.current = null + } + } + }, []) + + const startTutorial = useCallback( + (tutorial) => { + if (driverRef.current) { + driverRef.current.destroy() + } + + // If tutorial specifies pages and we're not on any of them, navigate first + if (tutorial.pages?.length && !tutorial.pages.includes(router.pathname)) { + router.push(tutorial.pages[0]).then(() => { + // Small delay to let the page render before starting the tour + setTimeout(() => runDriver(tutorial), 500) + }) + return + } + + runDriver(tutorial) + }, + [router] + ) + + const runDriver = useCallback((tutorial) => { + setActiveTutorial(tutorial) + + const driverObj = driver({ + showProgress: true, + animate: true, + allowClose: true, + overlayColor: 'rgba(0, 0, 0, 0.6)', + stagePadding: 8, + stageRadius: 8, + popoverClass: 'cipp-tutorial-popover', + nextBtnText: 'Next →', + prevBtnText: '← Back', + doneBtnText: 'Done ✓', + progressText: '{{current}} of {{total}}', + steps: tutorial.steps, + onDestroyed: () => { + storeCompletedTutorial(tutorial.id) + setCompletedIds(getCompletedTutorials()) + setActiveTutorial(null) + driverRef.current = null + }, + }) + + driverRef.current = driverObj + driverObj.drive() + }, []) + + const resetProgress = useCallback(() => { + resetCompletedTutorials() + setCompletedIds([]) + }, []) + + const getTutorialsForPage = useCallback( + (pathname) => { + return tutorials.filter((t) => !t.pages || t.pages.length === 0 || t.pages.includes(pathname)) + }, + [tutorials] + ) + + const value = useMemo( + () => ({ + tutorials, + activeTutorial, + completedIds, + startTutorial, + resetProgress, + getTutorialsForPage, + }), + [tutorials, activeTutorial, completedIds, startTutorial, resetProgress, getTutorialsForPage] + ) + + return {children} +} + +export const useTutorials = () => useContext(TutorialContext) diff --git a/src/data/AuditLogTemplates.json b/src/data/AuditLogTemplates.json index 1762fb2eb7bb..68f87b52bdf6 100644 --- a/src/data/AuditLogTemplates.json +++ b/src/data/AuditLogTemplates.json @@ -439,18 +439,10 @@ "label": "updated user" } }, - { - "Property": { "value": "String", "label": "SecuredAccessPassData" }, - "Operator": { "value": "ne", "label": "Not Equals to" }, - "Input": { - "value": "[]", - "label": "[]" - } - }, { "Property": { "value": "String", "label": "SecuredAccessPassData" }, "Operator": { "value": "like", "label": "Like" }, - "Input": { "value": "*" } + "Input": { "value": "*PassId*" } } ] } diff --git a/src/data/CIPPDBCacheTypes.json b/src/data/CIPPDBCacheTypes.json index 8742001441cd..0d0588d2b624 100644 --- a/src/data/CIPPDBCacheTypes.json +++ b/src/data/CIPPDBCacheTypes.json @@ -244,6 +244,11 @@ "friendlyName": "Mailboxes", "description": "All Exchange Online mailboxes" }, + { + "type": "HVEAccounts", + "friendlyName": "HVE Accounts", + "description": "High Volume Email accounts" + }, { "type": "CASMailboxes", "friendlyName": "CAS Mailboxes", diff --git a/src/data/ContainerLogPresets.json b/src/data/ContainerLogPresets.json new file mode 100644 index 000000000000..71f3dc5b8e93 --- /dev/null +++ b/src/data/ContainerLogPresets.json @@ -0,0 +1,52 @@ +[ + { + "name": "Recent Errors (Last 1h)", + "id": "cl-preset-errors-1h", + "query": "where Level in (\"ERR\", \"CRT\")\n| where Timestamp > ago(1h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Warnings & Errors (Last 24h)", + "id": "cl-preset-warn-err-24h", + "query": "where Level in (\"ERR\", \"CRT\", \"WRN\")\n| where Timestamp > ago(24h)\n| take 1000\n| sort by Timestamp desc" + }, + { + "name": "All Logs (Last 15 min)", + "id": "cl-preset-all-15m", + "query": "where Timestamp > ago(15m)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Startup Logs", + "id": "cl-preset-startup", + "query": "where Message contains \"Starting\"\n| where Message !contains \"heartbeat\"\n| take 200\n| sort by Timestamp desc" + }, + { + "name": "Graph API Errors", + "id": "cl-preset-graph-errors", + "query": "where Level in (\"ERR\", \"CRT\")\n| where Message matches regex \"graph|Graph|GRAPH\"\n| where Timestamp > ago(24h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Token / Auth Issues", + "id": "cl-preset-auth", + "query": "where Message matches regex \"token|auth|unauthorized|forbidden|401|403\"\n| where Timestamp > ago(24h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Timeout Errors", + "id": "cl-preset-timeouts", + "query": "where Message matches regex \"timeout|timed out|TaskCanceled\"\n| where Timestamp > ago(24h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "All Errors (Search All Files)", + "id": "cl-preset-all-errors", + "query": "search all files\n| where Level in (\"ERR\", \"CRT\")\n| take 1000\n| sort by Timestamp desc" + }, + { + "name": "Standards Processing", + "id": "cl-preset-standards", + "query": "where Message contains \"Standard\"\n| where Timestamp > ago(24h)\n| take 500\n| sort by Timestamp desc" + }, + { + "name": "Full Log (Last 1h, no heartbeats)", + "id": "cl-preset-full-clean", + "query": "where Timestamp > ago(1h)\n| where Message !contains \"heartbeat\"\n| take 1000\n| sort by Timestamp desc" + } +] diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 52df55dd4726..83872799e810 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -144,7 +144,7 @@ ], "multiple": false, "condition": { - "field": "Sherweb.migrationMethods", + "field": "Sherweb.migrationMethods.value", "compareType": "is", "compareValue": "buyAndCancel" } @@ -181,7 +181,7 @@ "placeholder": "Enter your Pax8 Client ID", "required": true, "condition": { - "field": "Sherweb.migrateFrom", + "field": "Sherweb.migrateFrom.value", "compareType": "is", "compareValue": "Pax8" } @@ -193,7 +193,7 @@ "placeholder": "Enter your Pax Client Secret", "required": true, "condition": { - "field": "Sherweb.migrateFrom", + "field": "Sherweb.migrateFrom.value", "compareType": "is", "compareValue": "Pax8" } @@ -614,6 +614,50 @@ "action": "disable" } }, + { + "type": "switch", + "name": "Hudu.HideEmptyRoles", + "label": "Hide Empty Roles in Magic Dash", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "switch", + "name": "Hudu.IncludeParterCenterLink", + "label": "Include link to Partner Center Service management page (partner.microsoft.com)", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "switch", + "name": "Hudu.IncludeDefenderLink", + "label": "Include link to Defender Portal (security.microsoft.com)", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "switch", + "name": "Hudu.IncludeComplianceLink", + "label": "Include link to Compliance Portal (compliance.microsoft.com)", + "condition": { + "field": "Hudu.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, { "_comment": "I have added this switch as a logic check for the Hudu integration script to check against when CIPP first connects to the Hudu Instance via Connect-HuduAPI.ps1", "type": "switch", @@ -938,5 +982,35 @@ } ], "mappingRequired": false + }, + { + "name": "ConnectWise PSA", + "id": "ConnectWisePSA", + "type": "ConnectWisePSA", + "cat": "Ticketing", + "logo": "/assets/integrations/connectwise.png", + "comingSoon": true, + "description": "Enable the ConnectWise PSA integration to send alerts to your ticketing system.", + "mappingRequired": false + }, + { + "name": "Autotask PSA", + "id": "AutotaskPSA", + "type": "AutotaskPSA", + "cat": "Ticketing", + "logo": "/assets/integrations/autotask.png", + "comingSoon": true, + "description": "Enable the Autotask PSA integration to send alerts to your ticketing system.", + "mappingRequired": false + }, + { + "name": "Kaseya BMS", + "id": "KaseyaBMS", + "type": "KaseyaBMS", + "cat": "Ticketing", + "logo": "/assets/integrations/kaseya.svg", + "comingSoon": true, + "description": "Enable the Kaseya BMS integration to send alerts to your ticketing system.", + "mappingRequired": false } ] diff --git a/src/data/alerts.json b/src/data/alerts.json index 041719bf5ca9..e0f7ffc9b9a8 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -122,10 +122,20 @@ "name": "QuotaUsed", "label": "Alert on % mailbox quota used", "requiresInput": true, - "inputType": "textField", - "inputLabel": "Enter quota percentage", - "inputName": "QuotaUsedQuota", - "recommendedRunInterval": "4h" + "multipleInput": true, + "inputs": [ + { + "inputType": "textField", + "inputLabel": "Quota percentage threshold (default: 90)", + "inputName": "QuotaUsedQuota" + }, + { + "inputType": "textField", + "inputLabel": "Excluded mailbox UPNs (comma-separated). Supports custom variables like %excludefrommailboxalert% defined under CIPP > Settings > Custom Variables at tenant or AllTenants scope.", + "inputName": "QuotaUsedExcludedMailboxes" + } + ], + "recommendedRunInterval": "1d" }, { "name": "SharePointQuota", @@ -134,7 +144,7 @@ "inputType": "textField", "inputLabel": "Enter quota percentage", "inputName": "SharePointQuota", - "recommendedRunInterval": "4h" + "recommendedRunInterval": "1d" }, { "name": "OneDriveQuota", @@ -143,7 +153,7 @@ "inputType": "textField", "inputLabel": "Enter quota percentage (default: 90)", "inputName": "OneDriveQuota", - "recommendedRunInterval": "4h" + "recommendedRunInterval": "1d" }, { "name": "ExpiringLicenses", @@ -602,5 +612,20 @@ "label": "Alert on new Check phishing extension detections", "recommendedRunInterval": "30m", "description": "Monitors for new phishing site detections reported by the Check browser extension. Alerts when a user visits a page that the extension flags as a potential credential phishing or AiTM attack. Requires the Check browser extension to be deployed to users." + }, + { + "name": "UserReportedPhishing", + "label": "Alert on emails reported by users via Outlook Report Phishing", + "recommendedRunInterval": "4h", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "number", + "inputLabel": "Hours to look back (default: 24)", + "inputName": "HoursBack" + } + ], + "description": "Monitors for emails reported by users through Outlook's built-in Report Phishing feature. Alerts when new user-reported email threat submissions are found in Microsoft Defender. Requires ThreatSubmission.ReadWrite.All permission." } ] diff --git a/src/data/conditionalAccessSchema.json b/src/data/conditionalAccessSchema.json new file mode 100644 index 000000000000..e6161261854e --- /dev/null +++ b/src/data/conditionalAccessSchema.json @@ -0,0 +1,664 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "microsoft.graph.conditionalAccessPolicy", + "title": "Conditional Access Policy", + "description": "Schema derived from the Microsoft Graph v1.0 conditionalAccessPolicy resource type. Reference: https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", + "type": "object", + "required": ["displayName", "state", "conditions"], + "properties": { + "displayName": { + "type": "string", + "title": "Display Name", + "description": "A display name for the policy.", + "minLength": 1, + "maxLength": 256 + }, + "state": { + "type": "string", + "title": "Policy State", + "description": "The state of the policy.", + "enum": ["enabled", "disabled", "enabledForReportingButNotEnforced"], + "enumLabels": { + "enabled": "Enabled", + "disabled": "Disabled", + "enabledForReportingButNotEnforced": "Report-only" + } + }, + "conditions": { + "$ref": "#/$defs/conditionalAccessConditionSet" + }, + "grantControls": { + "$ref": "#/$defs/conditionalAccessGrantControls" + }, + "sessionControls": { + "$ref": "#/$defs/conditionalAccessSessionControls" + } + }, + "$defs": { + "conditionalAccessConditionSet": { + "type": "object", + "title": "Conditions", + "description": "Rules that must be met for the policy to apply.", + "required": ["users", "applications", "clientAppTypes"], + "properties": { + "users": { + "$ref": "#/$defs/conditionalAccessUsers" + }, + "applications": { + "$ref": "#/$defs/conditionalAccessApplications" + }, + "clientAppTypes": { + "type": "array", + "title": "Client App Types", + "description": "Client application types included in the policy.", + "items": { + "type": "string", + "enum": ["all", "browser", "mobileAppsAndDesktopClients", "exchangeActiveSync", "easSupported", "other"] + }, + "enumLabels": { + "all": "All", + "browser": "Browser", + "mobileAppsAndDesktopClients": "Mobile apps and desktop clients", + "exchangeActiveSync": "Exchange ActiveSync", + "easSupported": "EAS supported", + "other": "Other clients" + } + }, + "platforms": { + "$ref": "#/$defs/conditionalAccessPlatforms" + }, + "locations": { + "$ref": "#/$defs/conditionalAccessLocations" + }, + "devices": { + "$ref": "#/$defs/conditionalAccessDevices" + }, + "clientApplications": { + "$ref": "#/$defs/conditionalAccessClientApplications" + }, + "signInRiskLevels": { + "type": "array", + "title": "Sign-in Risk Levels", + "description": "Sign-in risk levels included in the policy. Requires Entra ID P2.", + "items": { + "type": "string", + "enum": ["low", "medium", "high", "hidden", "none"] + }, + "enumLabels": { + "low": "Low", + "medium": "Medium", + "high": "High", + "hidden": "Hidden", + "none": "No risk" + }, + "requiresLicense": "AAD_PREMIUM_P2" + }, + "userRiskLevels": { + "type": "array", + "title": "User Risk Levels", + "description": "User risk levels included in the policy. Requires Entra ID P2.", + "items": { + "type": "string", + "enum": ["low", "medium", "high", "hidden", "none"] + }, + "enumLabels": { + "low": "Low", + "medium": "Medium", + "high": "High", + "hidden": "Hidden", + "none": "No risk" + }, + "requiresLicense": "AAD_PREMIUM_P2" + }, + "servicePrincipalRiskLevels": { + "type": "array", + "title": "Service Principal Risk Levels", + "description": "Service principal risk levels included in the policy.", + "items": { + "type": "string", + "enum": ["low", "medium", "high", "none"] + }, + "enumLabels": { + "low": "Low", + "medium": "Medium", + "high": "High", + "none": "No risk" + } + }, + "insiderRiskLevels": { + "type": "string", + "title": "Insider Risk Levels", + "description": "Insider risk levels included in the policy.", + "enum": ["minor", "moderate", "elevated"], + "enumLabels": { + "minor": "Minor", + "moderate": "Moderate", + "elevated": "Elevated" + } + }, + "authenticationFlows": { + "$ref": "#/$defs/conditionalAccessAuthenticationFlows" + } + } + }, + "conditionalAccessUsers": { + "type": "object", + "title": "Users and Groups", + "description": "Users, groups, and roles included in and excluded from the policy.", + "properties": { + "includeUsers": { + "type": "array", + "title": "Include Users", + "description": "User IDs in scope, or 'All', 'None', 'GuestsOrExternalUsers'.", + "items": { "type": "string" }, + "specialValues": ["All", "None", "GuestsOrExternalUsers"], + "graphLookup": "users" + }, + "excludeUsers": { + "type": "array", + "title": "Exclude Users", + "description": "User IDs excluded from scope.", + "items": { "type": "string" }, + "specialValues": ["GuestsOrExternalUsers"], + "graphLookup": "users" + }, + "includeGroups": { + "type": "array", + "title": "Include Groups", + "description": "Group IDs in scope of the policy.", + "items": { "type": "string" }, + "graphLookup": "groups" + }, + "excludeGroups": { + "type": "array", + "title": "Exclude Groups", + "description": "Group IDs excluded from the policy.", + "items": { "type": "string" }, + "graphLookup": "groups" + }, + "includeRoles": { + "type": "array", + "title": "Include Roles", + "description": "Directory role IDs in scope of the policy.", + "items": { "type": "string" }, + "graphLookup": "directoryRoles" + }, + "excludeRoles": { + "type": "array", + "title": "Exclude Roles", + "description": "Directory role IDs excluded from the policy.", + "items": { "type": "string" }, + "graphLookup": "directoryRoles" + }, + "includeGuestsOrExternalUsers": { + "$ref": "#/$defs/conditionalAccessGuestsOrExternalUsers" + }, + "excludeGuestsOrExternalUsers": { + "$ref": "#/$defs/conditionalAccessGuestsOrExternalUsers" + } + } + }, + "conditionalAccessGuestsOrExternalUsers": { + "type": "object", + "title": "Guests or External Users", + "description": "Internal guests or external user types.", + "properties": { + "guestOrExternalUserTypes": { + "type": "string", + "title": "Guest or External User Types", + "description": "Multi-valued flags. Combine with commas.", + "flagEnum": ["none", "internalGuest", "b2bCollaborationGuest", "b2bCollaborationMember", "b2bDirectConnectUser", "otherExternalUser", "serviceProvider"], + "flagEnumLabels": { + "none": "None", + "internalGuest": "Internal guest", + "b2bCollaborationGuest": "B2B collaboration guest", + "b2bCollaborationMember": "B2B collaboration member", + "b2bDirectConnectUser": "B2B direct connect user", + "otherExternalUser": "Other external user", + "serviceProvider": "Service provider" + } + }, + "externalTenants": { + "$ref": "#/$defs/conditionalAccessExternalTenants" + } + } + }, + "conditionalAccessExternalTenants": { + "type": "object", + "title": "External Tenants", + "description": "External tenant scope.", + "properties": { + "@odata.type": { + "type": "string", + "title": "Membership Kind", + "description": "Whether to enumerate or specify all tenants.", + "enum": [ + "#microsoft.graph.conditionalAccessAllExternalTenants", + "#microsoft.graph.conditionalAccessEnumeratedExternalTenants" + ], + "enumLabels": { + "#microsoft.graph.conditionalAccessAllExternalTenants": "All external tenants", + "#microsoft.graph.conditionalAccessEnumeratedExternalTenants": "Specific tenants" + } + }, + "members": { + "type": "array", + "title": "Tenant IDs", + "description": "List of tenant IDs when using enumerated membership.", + "items": { "type": "string" }, + "visibleWhen": { + "field": "@odata.type", + "value": "#microsoft.graph.conditionalAccessEnumeratedExternalTenants" + } + } + } + }, + "conditionalAccessApplications": { + "type": "object", + "title": "Cloud Apps or Actions", + "description": "Applications and user actions included in and excluded from the policy.", + "properties": { + "includeApplications": { + "type": "array", + "title": "Include Applications", + "description": "Application client IDs the policy applies to, or 'All', 'Office365', 'MicrosoftAdminPortals'.", + "items": { "type": "string" }, + "specialValues": ["All", "Office365", "MicrosoftAdminPortals"], + "specialValueLabels": { + "All": "All cloud apps", + "Office365": "Office 365", + "MicrosoftAdminPortals": "Microsoft Admin Portals" + }, + "graphLookup": "servicePrincipals" + }, + "excludeApplications": { + "type": "array", + "title": "Exclude Applications", + "description": "Application client IDs explicitly excluded.", + "items": { "type": "string" }, + "graphLookup": "servicePrincipals" + }, + "includeUserActions": { + "type": "array", + "title": "User Actions", + "description": "User actions to include instead of cloud apps.", + "items": { + "type": "string", + "enum": ["urn:user:registersecurityinfo", "urn:user:registerdevice"] + }, + "enumLabels": { + "urn:user:registersecurityinfo": "Register security information", + "urn:user:registerdevice": "Register or join devices" + } + }, + "includeAuthenticationContextClassReferences": { + "type": "array", + "title": "Authentication Context", + "description": "Authentication context class references included.", + "items": { "type": "string" } + }, + "applicationFilter": { + "$ref": "#/$defs/conditionalAccessFilter", + "title": "Application Filter", + "description": "Dynamic filter rule for applications." + } + } + }, + "conditionalAccessPlatforms": { + "type": "object", + "title": "Device Platforms", + "description": "Device platforms included in and excluded from the policy.", + "properties": { + "includePlatforms": { + "type": "array", + "title": "Include Platforms", + "description": "Platforms the policy applies to.", + "items": { + "type": "string", + "enum": ["android", "iOS", "windows", "windowsPhone", "macOS", "linux", "all"] + }, + "enumLabels": { + "android": "Android", + "iOS": "iOS", + "windows": "Windows", + "windowsPhone": "Windows Phone", + "macOS": "macOS", + "linux": "Linux", + "all": "All platforms" + } + }, + "excludePlatforms": { + "type": "array", + "title": "Exclude Platforms", + "description": "Platforms excluded from the policy.", + "items": { + "type": "string", + "enum": ["android", "iOS", "windows", "windowsPhone", "macOS", "linux"] + }, + "enumLabels": { + "android": "Android", + "iOS": "iOS", + "windows": "Windows", + "windowsPhone": "Windows Phone", + "macOS": "macOS", + "linux": "Linux" + } + } + } + }, + "conditionalAccessLocations": { + "type": "object", + "title": "Locations", + "description": "Locations included in and excluded from the policy.", + "properties": { + "includeLocations": { + "type": "array", + "title": "Include Locations", + "description": "Named location IDs or 'All', 'AllTrusted'.", + "items": { "type": "string" }, + "specialValues": ["All", "AllTrusted"], + "specialValueLabels": { + "All": "Any location", + "AllTrusted": "All trusted locations" + }, + "graphLookup": "namedLocations" + }, + "excludeLocations": { + "type": "array", + "title": "Exclude Locations", + "description": "Named location IDs excluded.", + "items": { "type": "string" }, + "specialValues": ["AllTrusted"], + "specialValueLabels": { + "AllTrusted": "All trusted locations" + }, + "graphLookup": "namedLocations" + } + } + }, + "conditionalAccessDevices": { + "type": "object", + "title": "Devices", + "description": "Device filter for the policy.", + "properties": { + "deviceFilter": { + "$ref": "#/$defs/conditionalAccessFilter", + "title": "Device Filter", + "description": "Dynamic filter rule for devices using device properties." + } + } + }, + "conditionalAccessFilter": { + "type": "object", + "title": "Filter", + "description": "Dynamic filter with rule syntax.", + "properties": { + "mode": { + "type": "string", + "title": "Filter Mode", + "description": "Whether to include or exclude matching items.", + "enum": ["include", "exclude"], + "enumLabels": { + "include": "Include filtered items", + "exclude": "Exclude filtered items" + } + }, + "rule": { + "type": "string", + "title": "Filter Rule", + "description": "Dynamic membership rule expression. Syntax matches Entra ID dynamic group rules." + } + }, + "required": ["mode", "rule"] + }, + "conditionalAccessClientApplications": { + "type": "object", + "title": "Workload Identities", + "description": "Service principals and workload identities included in and excluded from the policy.", + "properties": { + "includeServicePrincipals": { + "type": "array", + "title": "Include Service Principals", + "description": "Service principal IDs, or 'ServicePrincipalsInMyTenant'.", + "items": { "type": "string" }, + "specialValues": ["ServicePrincipalsInMyTenant"], + "specialValueLabels": { + "ServicePrincipalsInMyTenant": "All service principals" + } + }, + "excludeServicePrincipals": { + "type": "array", + "title": "Exclude Service Principals", + "description": "Service principal IDs excluded.", + "items": { "type": "string" } + }, + "servicePrincipalFilter": { + "$ref": "#/$defs/conditionalAccessFilter", + "title": "Service Principal Filter", + "description": "Dynamic filter rule for service principals." + } + } + }, + "conditionalAccessAuthenticationFlows": { + "type": "object", + "title": "Authentication Flows", + "description": "Authentication flow types in scope.", + "properties": { + "transferMethods": { + "type": "string", + "title": "Transfer Methods", + "description": "Transfer methods in scope for the policy.", + "enum": ["none", "deviceCodeFlow", "authenticationTransfer"], + "enumLabels": { + "none": "None", + "deviceCodeFlow": "Device code flow", + "authenticationTransfer": "Authentication transfer" + } + } + } + }, + "conditionalAccessGrantControls": { + "type": "object", + "title": "Grant Controls", + "description": "Grant controls that must be fulfilled to pass the policy.", + "properties": { + "operator": { + "type": "string", + "title": "Control Operator", + "description": "How multiple controls relate to each other.", + "enum": ["AND", "OR"], + "enumLabels": { + "AND": "Require all selected controls", + "OR": "Require one of the selected controls" + } + }, + "builtInControls": { + "type": "array", + "title": "Built-in Controls", + "description": "Built-in grant controls required by the policy.", + "items": { + "type": "string", + "enum": ["block", "mfa", "compliantDevice", "domainJoinedDevice", "approvedApplication", "compliantApplication", "passwordChange", "riskRemediation"] + }, + "enumLabels": { + "block": "Block access", + "mfa": "Require multifactor authentication", + "compliantDevice": "Require device to be marked as compliant", + "domainJoinedDevice": "Require Microsoft Entra hybrid joined device", + "approvedApplication": "Require approved client app", + "compliantApplication": "Require app protection policy", + "passwordChange": "Require password change", + "riskRemediation": "Require risk remediation" + }, + "constraints": { + "mutuallyExclusive": [["block"], ["mfa", "compliantDevice", "domainJoinedDevice", "approvedApplication", "compliantApplication", "passwordChange", "riskRemediation"]], + "passwordChangeRequires": ["mfa"], + "riskRemediationExcludes": ["passwordChange"] + } + }, + "customAuthenticationFactors": { + "type": "array", + "title": "Custom Controls", + "description": "Custom control IDs required by the policy.", + "items": { "type": "string" } + }, + "termsOfUse": { + "type": "array", + "title": "Terms of Use", + "description": "Terms of use IDs required by the policy.", + "items": { "type": "string" } + }, + "authenticationStrength": { + "$ref": "#/$defs/authenticationStrengthPolicy" + } + } + }, + "authenticationStrengthPolicy": { + "type": "object", + "title": "Authentication Strength", + "description": "Authentication strength policy required. Use instead of or alongside builtInControls.", + "properties": { + "id": { + "type": "string", + "title": "Authentication Strength Policy", + "description": "ID of the authentication strength policy.", + "graphLookup": "authenticationStrengthPolicies", + "wellKnownValues": { + "00000000-0000-0000-0000-000000000002": "Multifactor authentication", + "00000000-0000-0000-0000-000000000003": "Passwordless MFA", + "00000000-0000-0000-0000-000000000004": "Phishing-resistant MFA" + } + } + } + }, + "conditionalAccessSessionControls": { + "type": "object", + "title": "Session Controls", + "description": "Session controls enforced after sign-in.", + "properties": { + "applicationEnforcedRestrictions": { + "$ref": "#/$defs/applicationEnforcedRestrictionsSessionControl" + }, + "cloudAppSecurity": { + "$ref": "#/$defs/cloudAppSecuritySessionControl" + }, + "signInFrequency": { + "$ref": "#/$defs/signInFrequencySessionControl" + }, + "persistentBrowser": { + "$ref": "#/$defs/persistentBrowserSessionControl" + }, + "disableResilienceDefaults": { + "type": "boolean", + "title": "Disable Resilience Defaults", + "description": "When true, Entra ID will not extend existing sessions based on information collected prior to an outage." + } + } + }, + "applicationEnforcedRestrictionsSessionControl": { + "type": "object", + "title": "App Enforced Restrictions", + "description": "Enforce application restrictions. Only Exchange Online and SharePoint Online support this.", + "properties": { + "isEnabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether application enforced restrictions are enabled." + } + } + }, + "cloudAppSecuritySessionControl": { + "type": "object", + "title": "Conditional Access App Control", + "description": "Apply Defender for Cloud Apps controls.", + "properties": { + "isEnabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether cloud app security control is enabled." + }, + "cloudAppSecurityType": { + "type": "string", + "title": "Control Type", + "description": "Type of cloud app security enforcement.", + "enum": ["mcasConfigured", "monitorOnly", "blockDownloads"], + "enumLabels": { + "mcasConfigured": "Use custom policy", + "monitorOnly": "Monitor only", + "blockDownloads": "Block downloads" + } + } + } + }, + "signInFrequencySessionControl": { + "type": "object", + "title": "Sign-in Frequency", + "description": "Enforce periodic reauthentication.", + "properties": { + "isEnabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether sign-in frequency control is enabled." + }, + "value": { + "type": "integer", + "title": "Frequency Value", + "description": "Number of hours or days.", + "minimum": 1 + }, + "type": { + "type": "string", + "title": "Frequency Unit", + "description": "Unit of the sign-in frequency.", + "enum": ["hours", "days"], + "enumLabels": { + "hours": "Hours", + "days": "Days" + } + }, + "frequencyInterval": { + "type": "string", + "title": "Frequency Interval", + "description": "Whether frequency is time-based or every sign-in.", + "enum": ["timeBased", "everyTime"], + "enumLabels": { + "timeBased": "Time-based (use value/type above)", + "everyTime": "Every time" + } + }, + "authenticationType": { + "type": "string", + "title": "Authentication Type", + "description": "Which authentication types this applies to.", + "enum": ["primaryAndSecondaryAuthentication", "secondaryAuthentication"], + "enumLabels": { + "primaryAndSecondaryAuthentication": "Primary and secondary authentication", + "secondaryAuthentication": "Secondary authentication only" + } + } + } + }, + "persistentBrowserSessionControl": { + "type": "object", + "title": "Persistent Browser Session", + "description": "Whether to persist cookies after browser close.", + "properties": { + "isEnabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether persistent browser session control is enabled." + }, + "mode": { + "type": "string", + "title": "Mode", + "description": "Whether browser sessions should always or never persist.", + "enum": ["always", "never"], + "enumLabels": { + "always": "Always persistent", + "never": "Never persistent" + } + } + } + } + } +} diff --git a/src/data/dashboardv2-demo-data.js b/src/data/dashboardv2-demo-data.js index e5e23eee579a..4f0db4c12aad 100644 --- a/src/data/dashboardv2-demo-data.js +++ b/src/data/dashboardv2-demo-data.js @@ -1,8 +1,8 @@ // Demo data structure matching Zero Trust Assessment export const dashboardDemoData = { - ExecutedAt: "2025-12-16T10:00:00Z", - TenantName: "Demo Tenant", - Domain: "demo.contoso.com", + ExecutedAt: '2025-12-16T10:00:00Z', + TenantName: 'Demo Tenant', + Domain: 'demo.contoso.com', TestResultSummary: { IdentityPassed: 85, IdentityTotal: 100, @@ -13,100 +13,100 @@ export const dashboardDemoData = { }, TenantInfo: { TenantOverview: { - UserCount: 1250, - GuestCount: 85, - GroupCount: 340, - ApplicationCount: 156, - DeviceCount: 765, - ManagedDeviceCount: 733, + UserCount: 0, + GuestCount: 0, + GroupCount: 0, + ApplicationCount: 0, + DeviceCount: 0, + ManagedDeviceCount: 0, }, OverviewCaMfaAllUsers: { description: - "Over the past 30 days, 68.5% of sign-ins were protected by conditional access policies enforcing multifactor authentication.", + 'Over the past 30 days, 68.5% of sign-ins were protected by conditional access policies enforcing multifactor authentication.', nodes: [ - { source: "User sign in", target: "No CA applied", value: 394 }, - { source: "User sign in", target: "CA applied", value: 856 }, - { source: "CA applied", target: "No MFA", value: 146 }, - { source: "CA applied", target: "MFA", value: 710 }, + { source: 'User sign in', target: 'No CA applied', value: 394 }, + { source: 'User sign in', target: 'CA applied', value: 856 }, + { source: 'CA applied', target: 'No MFA', value: 146 }, + { source: 'CA applied', target: 'MFA', value: 710 }, ], }, OverviewCaDevicesAllUsers: { - description: "Over the past 30 days, 71.2% of sign-ins were from compliant devices.", + description: 'Over the past 30 days, 71.2% of sign-ins were from compliant devices.', nodes: [ - { source: "User sign in", target: "Unmanaged", value: 500 }, - { source: "User sign in", target: "Managed", value: 1150 }, - { source: "Managed", target: "Non-compliant", value: 260 }, - { source: "Managed", target: "Compliant", value: 890 }, + { source: 'User sign in', target: 'Unmanaged', value: 500 }, + { source: 'User sign in', target: 'Managed', value: 1150 }, + { source: 'Managed', target: 'Non-compliant', value: 260 }, + { source: 'Managed', target: 'Compliant', value: 890 }, ], }, OverviewAuthMethodsPrivilegedUsers: { - description: "Authentication methods used by privileged users over the past 30 days.", + description: 'Authentication methods used by privileged users over the past 30 days.', nodes: [ - { source: "Users", target: "Single factor", value: 5 }, - { source: "Users", target: "Phishable", value: 28 }, - { source: "Users", target: "Phish resistant", value: 15 }, - { source: "Phishable", target: "Phone", value: 8 }, - { source: "Phishable", target: "Authenticator", value: 20 }, - { source: "Phish resistant", target: "Passkey", value: 12 }, - { source: "Phish resistant", target: "WHfB", value: 3 }, + { source: 'Users', target: 'Single factor', value: 5 }, + { source: 'Users', target: 'Phishable', value: 28 }, + { source: 'Users', target: 'Phish resistant', value: 15 }, + { source: 'Phishable', target: 'Phone', value: 8 }, + { source: 'Phishable', target: 'Authenticator', value: 20 }, + { source: 'Phish resistant', target: 'Passkey', value: 12 }, + { source: 'Phish resistant', target: 'WHfB', value: 3 }, ], }, OverviewAuthMethodsAllUsers: { - description: "Authentication methods used by all users over the past 30 days.", + description: 'Authentication methods used by all users over the past 30 days.', nodes: [ - { source: "Users", target: "Single factor", value: 120 }, - { source: "Users", target: "Phishable", value: 580 }, - { source: "Users", target: "Phish resistant", value: 550 }, - { source: "Phishable", target: "Phone", value: 180 }, - { source: "Phishable", target: "Authenticator", value: 400 }, - { source: "Phish resistant", target: "Passkey", value: 450 }, - { source: "Phish resistant", target: "WHfB", value: 100 }, + { source: 'Users', target: 'Single factor', value: 120 }, + { source: 'Users', target: 'Phishable', value: 580 }, + { source: 'Users', target: 'Phish resistant', value: 550 }, + { source: 'Phishable', target: 'Phone', value: 180 }, + { source: 'Phishable', target: 'Authenticator', value: 400 }, + { source: 'Phish resistant', target: 'Passkey', value: 450 }, + { source: 'Phish resistant', target: 'WHfB', value: 100 }, ], }, DeviceOverview: { DesktopDevicesSummary: { - description: "Desktop devices (Windows and macOS) by join type and compliance status.", + description: 'Desktop devices (Windows and macOS) by join type and compliance status.', nodes: [ // Level 1: Desktop devices to OS - { source: "Desktop devices", target: "Windows", value: 585 }, - { source: "Desktop devices", target: "macOS", value: 75 }, + { source: 'Desktop devices', target: 'Windows', value: 585 }, + { source: 'Desktop devices', target: 'macOS', value: 75 }, // Level 2: Windows to join types - { source: "Windows", target: "Entra joined", value: 285 }, - { source: "Windows", target: "Entra registered", value: 100 }, - { source: "Windows", target: "Entra hybrid joined", value: 200 }, + { source: 'Windows', target: 'Entra joined', value: 285 }, + { source: 'Windows', target: 'Entra registered', value: 100 }, + { source: 'Windows', target: 'Entra hybrid joined', value: 200 }, // Level 3: Windows join types to compliance - { source: "Entra joined", target: "Compliant", value: 171 }, - { source: "Entra joined", target: "Non-compliant", value: 42 }, - { source: "Entra joined", target: "Unmanaged", value: 72 }, - { source: "Entra hybrid joined", target: "Compliant", value: 50 }, - { source: "Entra hybrid joined", target: "Non-compliant", value: 23 }, - { source: "Entra hybrid joined", target: "Unmanaged", value: 127 }, - { source: "Entra registered", target: "Compliant", value: 60 }, - { source: "Entra registered", target: "Non-compliant", value: 40 }, - { source: "Entra registered", target: "Unmanaged", value: 0 }, + { source: 'Entra joined', target: 'Compliant', value: 171 }, + { source: 'Entra joined', target: 'Non-compliant', value: 42 }, + { source: 'Entra joined', target: 'Unmanaged', value: 72 }, + { source: 'Entra hybrid joined', target: 'Compliant', value: 50 }, + { source: 'Entra hybrid joined', target: 'Non-compliant', value: 23 }, + { source: 'Entra hybrid joined', target: 'Unmanaged', value: 127 }, + { source: 'Entra registered', target: 'Compliant', value: 60 }, + { source: 'Entra registered', target: 'Non-compliant', value: 40 }, + { source: 'Entra registered', target: 'Unmanaged', value: 0 }, // Level 2: macOS directly to compliance - { source: "macOS", target: "Compliant", value: 56 }, - { source: "macOS", target: "Non-compliant", value: 15 }, - { source: "macOS", target: "Unmanaged", value: 4 }, + { source: 'macOS', target: 'Compliant', value: 56 }, + { source: 'macOS', target: 'Non-compliant', value: 15 }, + { source: 'macOS', target: 'Unmanaged', value: 4 }, ], }, MobileSummary: { - description: "Mobile devices by compliance status.", + description: 'Mobile devices by compliance status.', nodes: [ - { source: "Mobile devices", target: "Android", value: 105 }, - { source: "Mobile devices", target: "iOS", value: 75 }, - { source: "Android", target: "Android (Company)", value: 72 }, - { source: "Android", target: "Android (Personal)", value: 33 }, - { source: "iOS", target: "iOS (Company)", value: 58 }, - { source: "iOS", target: "iOS (Personal)", value: 17 }, - { source: "Android (Company)", target: "Compliant", value: 60 }, - { source: "Android (Company)", target: "Non-compliant", value: 12 }, - { source: "Android (Personal)", target: "Compliant", value: 10 }, - { source: "Android (Personal)", target: "Non-compliant", value: 23 }, - { source: "iOS (Company)", target: "Compliant", value: 52 }, - { source: "iOS (Company)", target: "Non-compliant", value: 6 }, - { source: "iOS (Personal)", target: "Compliant", value: 11 }, - { source: "iOS (Personal)", target: "Non-compliant", value: 6 }, + { source: 'Mobile devices', target: 'Android', value: 105 }, + { source: 'Mobile devices', target: 'iOS', value: 75 }, + { source: 'Android', target: 'Android (Company)', value: 72 }, + { source: 'Android', target: 'Android (Personal)', value: 33 }, + { source: 'iOS', target: 'iOS (Company)', value: 58 }, + { source: 'iOS', target: 'iOS (Personal)', value: 17 }, + { source: 'Android (Company)', target: 'Compliant', value: 60 }, + { source: 'Android (Company)', target: 'Non-compliant', value: 12 }, + { source: 'Android (Personal)', target: 'Compliant', value: 10 }, + { source: 'Android (Personal)', target: 'Non-compliant', value: 23 }, + { source: 'iOS (Company)', target: 'Compliant', value: 52 }, + { source: 'iOS (Company)', target: 'Non-compliant', value: 6 }, + { source: 'iOS (Personal)', target: 'Compliant', value: 11 }, + { source: 'iOS (Personal)', target: 'Non-compliant', value: 6 }, ], }, ManagedDevices: { @@ -128,4 +128,4 @@ export const dashboardDemoData = { }, }, }, -}; +} diff --git a/src/data/portals.json b/src/data/portals.json index a4402305faca..874810c6d976 100644 --- a/src/data/portals.json +++ b/src/data/portals.json @@ -6,7 +6,7 @@ "variable": "initialDomainName", "target": "_blank", "external": true, - "icon": "GlobeAltIcon" + "icon": "Public" }, { "label": "Exchange", @@ -24,7 +24,7 @@ "variable": "defaultDomainName", "target": "_blank", "external": true, - "icon": "UsersIcon" + "icon": "Groups" }, { "label": "Teams", @@ -42,7 +42,7 @@ "variable": "defaultDomainName", "target": "_blank", "external": true, - "icon": "ServerIcon" + "icon": "Dns" }, { "label": "Intune", diff --git a/src/data/standards.json b/src/data/standards.json index cc708f747177..c1e87f49e615 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -126,16 +126,8 @@ { "name": "standards.AuditLog", "cat": "Global Standards", - "tag": [ - "CIS M365 6.0.1 (3.1.1)", - "mip_search_auditlog", - "NIST CSF 2.0 (DE.CM-09)" - ], - "appliesToTest": [ - "CISAMSEXO171", - "CISAMSEXO173", - "CIS_3_1_1" - ], + "tag": ["CIS M365 7.0.0 (3.1.1)", "mip_search_auditlog", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CISAMSEXO171", "CISAMSEXO173", "CIS_3_1_1"], "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", "executiveText": "Activates comprehensive activity logging across Microsoft 365 services to track user actions, system changes, and security events. This provides essential audit trails for compliance requirements, security investigations, and regulatory reporting.", "addedComponent": [], @@ -156,7 +148,7 @@ { "name": "standards.RestrictThirdPartyStorageServices", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (1.3.7)"], + "tag": ["CIS M365 7.0.0 (1.3.7)"], "appliesToTest": ["CIS_1_3_7"], "helpText": "Restricts third-party storage services in Microsoft 365 on the web by managing the Microsoft 365 on the web service principal. This disables integrations with services like Dropbox, Google Drive, Box, and other third-party storage providers.", "docsDescription": "Third-party storage can be enabled for users in Microsoft 365, allowing them to store and share documents using services such as Dropbox, alongside OneDrive and team sites. This standard ensures Microsoft 365 on the web third-party storage services are restricted by creating and disabling the Microsoft 365 on the web service principal (appId: c1f33bc0-bdb4-4248-ba9b-096807ddb43e). By using external storage services an organization may increase the risk of data breaches and unauthorized access to confidential information. Additionally, third-party services may not adhere to the same security standards as the organization, making it difficult to maintain data privacy and security. Impact is highly dependent upon current practices - if users do not use other storage providers, then minimal impact is likely. However, if users regularly utilize providers outside of the tenant this will affect their ability to continue to do so.", @@ -275,7 +267,7 @@ { "name": "standards.EnableCustomerLockbox", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (1.3.6)", "CustomerLockBoxEnabled"], + "tag": ["CIS M365 7.0.0 (1.3.6)", "CustomerLockBoxEnabled"], "appliesToTest": ["CIS_1_3_6"], "helpText": "**Requires Entra ID P2.** Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", "docsDescription": "**Requires Entra ID P2.** Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", @@ -336,7 +328,7 @@ "name": "standards.DisableGuestDirectory", "cat": "Global Standards", "tag": [ - "CIS M365 6.0.1 (5.1.6.2)", + "CIS M365 7.0.0 (5.1.6.2)", "CISA (MS.AAD.5.1v1)", "EIDSCA.AP14", "EIDSCA.ST08", @@ -367,12 +359,8 @@ { "name": "standards.DisableBasicAuthSMTP", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)"], - "appliesToTest": [ - "CISAMSEXO51", - "CIS_6_5_4", - "ZTNA21799" - ], + "tag": ["CIS M365 7.0.0 (6.5.4)", "NIST CSF 2.0 (PR.IR-01)"], + "appliesToTest": ["CISAMSEXO51", "CIS_6_5_4", "ZTNA21799"], "helpText": "Disables SMTP AUTH organization-wide, impacting POP and IMAP clients that rely on SMTP for sending emails. Default for new tenants. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission)", "docsDescription": "Disables tenant-wide SMTP basic authentication, including for all explicitly enabled users, impacting POP and IMAP clients that rely on SMTP for sending emails. For more information, see the [Microsoft documentation](https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission).", "executiveText": "Disables outdated email authentication methods that are vulnerable to security attacks, forcing applications and devices to use modern, more secure authentication protocols. This reduces the risk of email-based security breaches and credential theft.", @@ -394,17 +382,8 @@ { "name": "standards.ActivityBasedTimeout", "cat": "Global Standards", - "tag": [ - "CIS M365 6.0.1 (1.3.2)", - "spo_idle_session_timeout", - "NIST CSF 2.0 (PR.AA-03)" - ], - "appliesToTest": [ - "CIS_1_3_2", - "ZTNA21813", - "ZTNA21814", - "ZTNA21815" - ], + "tag": ["CIS M365 7.0.0 (1.3.2)", "spo_idle_session_timeout", "NIST CSF 2.0 (PR.AA-03)"], + "appliesToTest": ["CIS_1_3_2", "ZTNA21813", "ZTNA21814", "ZTNA21815"], "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", "executiveText": "Automatically logs out inactive users from Microsoft 365 applications after a specified time period to prevent unauthorized access to company data on unattended devices. This security measure protects against data breaches when employees leave workstations unlocked.", "addedComponent": [ @@ -434,7 +413,7 @@ "name": "standards.AuthMethodsSettings", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.6)", + "CIS M365 7.0.0 (5.2.3.6)", "EIDSCA.AG01", "EIDSCA.AG02", "EIDSCA.AG03", @@ -486,14 +465,358 @@ "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", "recommendedBy": [] }, + { + "name": "standards.AuthenticationMethods", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Enable or disable each method and optionally target specific groups.", + "docsDescription": "Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP.", + "executiveText": "Provides centralized control over all tenant authentication methods from a single standard. Administrators can enable phishing-resistant methods like FIDO2 and Microsoft Authenticator while disabling less secure options like SMS and Voice. Each method supports group-level targeting using wildcard group names, allowing staged rollouts and granular control.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "label": "Microsoft Authenticator", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorSoftwareOath", + "label": "Enable Software OTP in Authenticator", + "defaultValue": false, + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Number Matching", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorNumberMatching", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Show Application Name in Push Notifications", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayAppInfo", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Show Geographic Location in Push Notifications", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorDisplayLocation", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Companion App (Authenticator Lite)", + "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorCompanionApp", + "options": [ + { "label": "Microsoft managed", "value": "default" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ], + "condition": { + "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.FIDO2Enabled", + "label": "FIDO2 Security Keys", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.FIDO2Group", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.FIDO2Enabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.TAPEnabled", + "label": "Temporary Access Pass", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.TAPGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "TAP Usage Mode", + "name": "standards.AuthenticationMethods.TAPUsableOnce", + "options": [ + { "label": "Only Once", "value": "true" }, + { "label": "Multiple Logons", "value": "false" } + ], + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPDefaultLifetime", + "label": "TAP Default Lifetime (minutes)", + "defaultValue": 60, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPMinLifetime", + "label": "TAP Minimum Lifetime (minutes)", + "defaultValue": 60, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPMaxLifetime", + "label": "TAP Maximum Lifetime (minutes)", + "defaultValue": 480, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.TAPDefaultLength", + "label": "TAP Length (characters)", + "defaultValue": 8, + "condition": { + "field": "standards.AuthenticationMethods.TAPEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.SoftwareOathEnabled", + "label": "Third-Party Software OATH Tokens", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.SoftwareOathGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.SoftwareOathEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.HardwareOathEnabled", + "label": "Hardware OATH Tokens", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.HardwareOathGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.HardwareOathEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.SMSEnabled", + "label": "SMS", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.SMSGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.SMSEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.VoiceEnabled", + "label": "Voice Call", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.VoiceGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.VoiceEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.EmailEnabled", + "label": "Email OTP", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.EmailGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.EmailEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.x509CertificateEnabled", + "label": "Certificate-Based Authentication", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.x509CertificateGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.x509CertificateEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "switch", + "name": "standards.AuthenticationMethods.QRCodePinEnabled", + "label": "QR Code Pin", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.AuthenticationMethods.QRCodePinGroup", + "label": "Target Group Name (wildcard supported, blank = All Users)", + "required": false, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.QRCodeLifetimeInDays", + "label": "QR Code Lifetime (days, 1-395)", + "defaultValue": 365, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "number", + "name": "standards.AuthenticationMethods.QRCodePinLength", + "label": "QR Code PIN Length (8-20)", + "defaultValue": 8, + "condition": { + "field": "standards.AuthenticationMethods.QRCodePinEnabled", + "compareType": "is", + "compareValue": true + } + } + ], + "label": "Configure Authentication Methods", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-05-28", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", + "recommendedBy": ["CIPP"] + }, { "name": "standards.AdminSSPR", "cat": "Entra (AAD) Standards", "tag": ["EIDSCA.AP01"], - "appliesToTest": [ - "EIDSCAAP01", - "ZTNA21842" - ], + "appliesToTest": ["EIDSCAAP01", "ZTNA21842"], "helpText": "Controls whether administrators are allowed to use Self-Service Password Reset through the Microsoft Entra authorization policy.", "docsDescription": "Configures the allowedToUseSSPR property on the Microsoft Entra authorization policy. Microsoft documents this property as controlling whether administrators of the tenant can use Self-Service Password Reset. Use this standard to explicitly enable or disable administrator SSPR based on your security policy.", "executiveText": "Controls whether tenant administrators can reset their own passwords through Self-Service Password Reset. Disabling this capability forces privileged accounts through more controlled recovery processes and reduces the risk of self-service recovery being misused on administrative identities.", @@ -592,14 +915,8 @@ { "name": "standards.laps", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.5)", "SMB1001 (2.2)"], - "appliesToTest": [ - "CIS_5_1_4_5", - "SMB1001_2_2", - "ZTNA21953", - "ZTNA21955", - "ZTNA24560" - ], + "tag": ["CIS M365 7.0.0 (5.1.4.5)", "SMB1001 (2.2)"], + "appliesToTest": ["CIS_5_1_4_5", "SMB1001_2_2", "ZTNA21953", "ZTNA21955", "ZTNA24560"], "helpText": "Enables the tenant to use LAPS. You must still create a policy for LAPS to be active on all devices. Use the template standards to deploy this by default.", "docsDescription": "Enables the LAPS functionality on the tenant. Prerequisite for using Windows LAPS via Azure AD.", "executiveText": "Enables Local Administrator Password Solution (LAPS) capability, which automatically manages and rotates local administrator passwords on company computers. This significantly improves security by preventing the use of shared or static administrator passwords that could be exploited by attackers.", @@ -615,7 +932,7 @@ "name": "standards.PWdisplayAppInformationRequiredState", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.1)", + "CIS M365 7.0.0 (5.2.3.1)", "EIDSCA.AM03", "EIDSCA.AM04", "EIDSCA.AM06", @@ -664,8 +981,8 @@ { "name": "standards.PWcompanionAppAllowedState", "cat": "Entra (AAD) Standards", - "tag": ["EIDSCA.AM01"], - "appliesToTest": ["EIDSCAAM01"], + "tag": ["CIS M365 7.0.0 (5.2.3.10)", "EIDSCA.AM01"], + "appliesToTest": ["CIS_5_2_3_10", "EIDSCAAM01"], "helpText": "Sets the state of Authenticator Lite, Authenticator lite is a companion app for passwordless authentication.", "docsDescription": "Sets the Authenticator Lite state to enabled. This allows users to use the Authenticator Lite built into the Outlook app instead of the full Authenticator app.", "executiveText": "Enables a simplified authentication experience by allowing users to authenticate directly through Outlook without requiring a separate authenticator app. This improves user convenience while maintaining security standards for passwordless authentication.", @@ -733,11 +1050,7 @@ "name": "standards.EnableHardwareOAuth", "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)"], - "appliesToTest": [ - "SMB1001_2_5", - "SMB1001_2_6", - "SMB1001_2_9" - ], + "appliesToTest": ["SMB1001_2_5", "SMB1001_2_6", "SMB1001_2_9"], "helpText": "Enables the HardwareOath authenticationMethod for the tenant. This allows you to use hardware tokens for generating 6 digit MFA codes.", "docsDescription": "Enables Hardware OAuth tokens for the tenant. This allows users to use hardware tokens like a Yubikey for authentication.", "executiveText": "Enables physical hardware tokens that generate secure authentication codes, providing an alternative to smartphone-based authentication. This is particularly valuable for employees who cannot use mobile devices or require the highest security standards for accessing sensitive systems.", @@ -753,10 +1066,7 @@ "name": "standards.allowOAuthTokens", "cat": "Entra (AAD) Standards", "tag": ["EIDSCA.AT01", "EIDSCA.AT02"], - "appliesToTest": [ - "EIDSCAAT01", - "EIDSCAAT02" - ], + "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02"], "helpText": "Allows you to use any software OAuth token generator", "docsDescription": "Enables OTP Software OAuth tokens for the tenant. This allows users to use OTP codes generated via software, like a password manager to be used as an authentication method.", "executiveText": "Allows employees to use third-party authentication apps and password managers to generate secure login codes, providing flexibility in authentication methods while maintaining security standards. This accommodates diverse user preferences and existing security tools.", @@ -771,7 +1081,7 @@ { "name": "standards.FormsPhishingProtection", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (1.3.5)", "Security", "PhishingProtection"], + "tag": ["CIS M365 7.0.0 (1.3.5)", "Security", "PhishingProtection"], "appliesToTest": ["CIS_1_3_5"], "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.", "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.", @@ -788,15 +1098,10 @@ "name": "standards.TAP", "cat": "Entra (AAD) Standards", "tag": [], - "appliesToTest": [ - "EIDSCAAT01", - "EIDSCAAT02", - "ZTNA21845", - "ZTNA21846" - ], + "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", - "docsDescription": "Enables Temporary Password generation for the tenant.", - "executiveText": "Enables temporary access passwords that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passwords provide a secure way to restore access without compromising long-term security policies.", + "docsDescription": "Enables Temporary Access Pass generation for the tenant.", + "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ { "type": "autoComplete", @@ -810,7 +1115,7 @@ ] } ], - "label": "Enable Temporary Access Passwords", + "label": "Enable Temporary Access Passes (TAP)", "impact": "Low Impact", "impactColour": "info", "addedDate": "2022-03-15", @@ -820,7 +1125,7 @@ { "name": "standards.PasswordExpireDisabled", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (1.3.1)", "PWAgePolicyNew"], + "tag": ["CIS M365 7.0.0 (1.3.1)", "PWAgePolicyNew"], "appliesToTest": ["CIS_1_3_1", "ZTNA21811"], "helpText": "Disables the expiration of passwords for the tenant by setting the password expiration policy to never expire for any user.", "docsDescription": "Sets passwords to never expire for tenant, recommended to use in conjunction with secure password requirements.", @@ -836,10 +1141,7 @@ { "name": "standards.CustomBannedPasswordList", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 6.0.1 (5.2.3.2)", - "SMB1001 (2.1)" - ], + "tag": ["CIS M365 7.0.0 (5.2.3.2)", "SMB1001 (2.1)"], "appliesToTest": [ "CIS_5_2_3_2", "EIDSCAPR01", @@ -875,10 +1177,7 @@ "name": "standards.ExternalMFATrusted", "cat": "Entra (AAD) Standards", "tag": [], - "appliesToTest": [ - "ZTNA21803", - "ZTNA21804" - ], + "appliesToTest": ["ZTNA21803", "ZTNA21804"], "helpText": "Sets the state of the Cross-tenant access setting to trust external MFA. This allows guest users to use their home tenant MFA to access your tenant.", "executiveText": "Allows external partners and vendors to use their own organization's multi-factor authentication when accessing company resources, streamlining collaboration while maintaining security standards. This reduces friction for external users while ensuring they still meet authentication requirements.", "addedComponent": [ @@ -904,17 +1203,8 @@ { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", - "tag": [ - "CIS M365 6.0.1 (5.1.2.3)", - "CISA (MS.AAD.6.1v1)", - "SMB1001 (2.8)" - ], - "appliesToTest": [ - "CIS_5_1_2_3", - "SMB1001_2_8", - "ZTNA21772", - "ZTNA21787" - ], + "tag": ["CIS M365 7.0.0 (5.1.2.3)", "CISA (MS.AAD.6.1v1)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_2_3", "SMB1001_2_8", "ZTNA21772", "ZTNA21787"], "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles.", "docsDescription": "Users by default are allowed to create M365 tenants. This disables that so only admins can create new M365 tenants.", "executiveText": "Prevents regular employees from creating new Microsoft 365 organizations, ensuring all new tenants are properly managed and controlled by IT administrators. This prevents unauthorized shadow IT environments and maintains centralized governance over Microsoft 365 resources.", @@ -930,7 +1220,7 @@ "name": "standards.EnableAppConsentRequests", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.1.5.2)", + "CIS M365 7.0.0 (5.1.5.2)", "CISA (MS.AAD.9.1v1)", "EIDSCA.CP04", "EIDSCA.CR01", @@ -971,10 +1261,7 @@ "name": "standards.NudgeMFA", "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.5)"], - "appliesToTest": [ - "SMB1001_2_5", - "ZTNA21889" - ], + "appliesToTest": ["SMB1001_2_5", "ZTNA21889"], "helpText": "Sets the state of the registration campaign for the tenant", "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.", "executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.", @@ -1012,10 +1299,7 @@ "name": "standards.DisableM365GroupUsers", "cat": "Entra (AAD) Standards", "tag": ["CISA (MS.AAD.21.1v1)", "SMB1001 (2.8)"], - "appliesToTest": [ - "SMB1001_2_8", - "ZTNA21868" - ], + "appliesToTest": ["SMB1001_2_8", "ZTNA21868"], "helpText": "Restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "docsDescription": "Users by default are allowed to create M365 groups. This restricts M365 group creation to certain admin roles. This disables the ability to create Teams, SharePoint sites, Planner, etc", "executiveText": "Restricts the creation of Microsoft 365 groups, Teams, and SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces. This ensures proper governance, naming conventions, and resource management while maintaining oversight of all collaborative environments.", @@ -1039,18 +1323,14 @@ "name": "standards.DisableAppCreation", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.1.2.2)", + "CIS M365 7.0.0 (5.1.2.2)", "CISA (MS.AAD.4.1v1)", "EIDSCA.AP10", "Essential 8 (1175)", "NIST CSF 2.0 (PR.AA-05)", "SMB1001 (2.8)" ], - "appliesToTest": [ - "CIS_5_1_2_2", - "EIDSCAAP10", - "SMB1001_2_8" - ], + "appliesToTest": ["CIS_5_1_2_2", "EIDSCAAP10", "SMB1001_2_8"], "helpText": "Disables the ability for users to create App registrations in the tenant.", "docsDescription": "Disables the ability for users to create applications in Entra. Done to prevent breached accounts from creating an app to maintain access to the tenant, even after the breached account has been secured.", "executiveText": "Prevents regular employees from creating application registrations that could be used to maintain unauthorized access to company systems. This security measure ensures that only authorized IT personnel can create applications, reducing the risk of persistent security breaches through malicious applications.", @@ -1065,11 +1345,8 @@ { "name": "standards.BitLockerKeysForOwnedDevice", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.6)"], - "appliesToTest": [ - "CIS_5_1_4_6", - "ZTNA21954" - ], + "tag": ["CIS M365 7.0.0 (5.1.4.6)"], + "appliesToTest": ["CIS_5_1_4_6", "ZTNA21954"], "helpText": "Controls whether standard users can recover BitLocker keys for devices they own.", "docsDescription": "Updates the Microsoft Entra authorization policy that controls whether standard users can read BitLocker recovery keys for devices they own. Choose to restrict access for tighter security or allow self-service recovery when operational needs require it.", "executiveText": "Gives administrators centralized control over BitLocker recovery secrets—restrict access to ensure IT-assisted recovery flows, or allow self-service when rapid device unlocks are a priority.", @@ -1097,16 +1374,12 @@ "name": "standards.DisableSecurityGroupUsers", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.1.3.2)", + "CIS M365 7.0.0 (5.1.3.1)", "CISA (MS.AAD.20.1v1)", "NIST CSF 2.0 (PR.AA-05)", "SMB1001 (2.8)" ], - "appliesToTest": [ - "CIS_5_1_3_2", - "SMB1001_2_8", - "ZTNA21868" - ], + "appliesToTest": ["CIS_5_1_3_1", "SMB1001_2_8", "ZTNA21868"], "helpText": "Completely disables the creation of security groups by users. This also breaks the ability to manage groups themselves, or create Teams", "executiveText": "Restricts the creation of security groups to IT administrators only, preventing employees from creating unauthorized access groups that could bypass security controls. This ensures proper governance of access permissions and maintains centralized control over who can access what resources.", "addedComponent": [], @@ -1134,12 +1407,8 @@ { "name": "standards.DisableSelfServiceLicenses", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (1.3.4)", "SMB1001 (2.8)"], - "appliesToTest": [ - "CIS_1_3_4", - "EIDSCAAP05", - "SMB1001_2_8" - ], + "tag": ["CIS M365 7.0.0 (1.3.4)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_1_3_4", "EIDSCAAP05", "SMB1001_2_8"], "helpText": "**Requires 'Billing Administrator' GDAP role.** This standard disables all self service licenses and enables all exclusions", "executiveText": "Prevents employees from purchasing Microsoft 365 licenses independently, ensuring all software acquisitions go through proper procurement channels. This maintains budget control, prevents unauthorized spending, and ensures compliance with corporate licensing agreements.", "addedComponent": [ @@ -1166,10 +1435,7 @@ "name": "standards.DisableGuests", "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.8)"], - "appliesToTest": [ - "SMB1001_2_8", - "ZTNA21858" - ], + "appliesToTest": ["SMB1001_2_8", "ZTNA21858"], "helpText": "Blocks login for guest users that have not logged in for a number of days", "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", "addedComponent": [ @@ -1193,7 +1459,7 @@ "name": "standards.OauthConsent", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.1.5.1)", + "CIS M365 7.0.0 (5.1.5.1)", "CISA (MS.AAD.4.2v1)", "EIDSCA.AP08", "EIDSCA.AP09", @@ -1247,19 +1513,8 @@ { "name": "standards.GuestInvite", "cat": "Entra (AAD) Standards", - "tag": [ - "CISA (MS.AAD.18.1v1)", - "EIDSCA.AP04", - "EIDSCA.AP07", - "SMB1001 (2.8)" - ], - "appliesToTest": [ - "CIS_5_1_6_3", - "EIDSCAAP04", - "EIDSCAAP07", - "SMB1001_2_8", - "ZTNA21791" - ], + "tag": ["CISA (MS.AAD.18.1v1)", "EIDSCA.AP04", "EIDSCA.AP07", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_6_3", "EIDSCAAP04", "EIDSCAAP07", "SMB1001_2_8", "ZTNA21791"], "helpText": "This setting controls who can invite guests to your directory to collaborate on resources secured by your company, such as SharePoint sites or Azure resources.", "executiveText": "Controls who within the organization can invite external partners and vendors to access company resources, ensuring proper oversight of external access while enabling necessary business collaboration. This helps maintain security while supporting partnership and vendor relationships.", "addedComponent": [ @@ -1332,12 +1587,7 @@ "name": "standards.SecurityDefaults", "cat": "Entra (AAD) Standards", "tag": ["CISA (MS.AAD.11.1v1)", "SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)"], - "appliesToTest": [ - "SMB1001_2_5", - "SMB1001_2_6", - "SMB1001_2_9", - "ZTNA21843" - ], + "appliesToTest": ["SMB1001_2_5", "SMB1001_2_6", "SMB1001_2_9", "ZTNA21843"], "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access.", "docsDescription": "Enables SD for the tenant, which disables all forms of basic authentication and enforces users to configure MFA. Users are only prompted for MFA when a logon is considered 'suspect' by Microsoft.", "executiveText": "Activates Microsoft's baseline security configuration that requires multi-factor authentication and blocks legacy authentication methods. This provides essential security protection for organizations without complex conditional access policies, significantly improving security posture with minimal configuration.", @@ -1353,7 +1603,7 @@ "name": "standards.DisableSMS", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.5)", + "CIS M365 7.0.0 (5.2.3.5)", "EIDSCA.AS04", "NIST CSF 2.0 (PR.AA-03)", "SMB1001 (2.5)", @@ -1383,7 +1633,7 @@ "name": "standards.DisableVoice", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.5)", + "CIS M365 7.0.0 (5.2.3.5)", "EIDSCA.AV01", "NIST CSF 2.0 (PR.AA-03)", "SMB1001 (2.5)", @@ -1413,19 +1663,13 @@ "name": "standards.DisableEmail", "cat": "Entra (AAD) Standards", "tag": [ - "CIS M365 6.0.1 (5.2.3.7)", + "CIS M365 7.0.0 (5.2.3.7)", "NIST CSF 2.0 (PR.AA-03)", "SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.9)" ], - "appliesToTest": [ - "CIS_5_2_3_7", - "SMB1001_2_5", - "SMB1001_2_5_L4", - "SMB1001_2_6", - "SMB1001_2_9" - ], + "appliesToTest": ["CIS_5_2_3_7", "SMB1001_2_5", "SMB1001_2_5_L4", "SMB1001_2_6", "SMB1001_2_9"], "helpText": "This blocks users from using email as an MFA method. This disables the email OTP option for guest users, and instead prompts them to create a Microsoft account.", "executiveText": "Disables email-based authentication codes due to security concerns with email interception and account compromise. This forces users to adopt more secure authentication methods, particularly affecting guest users who must use stronger verification methods.", "addedComponent": [], @@ -1436,6 +1680,28 @@ "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", "recommendedBy": [] }, + { + "name": "standards.EmailAsAlternateLoginId", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures the tenant-wide Email as alternate login ID setting in Home Realm Discovery policy. Enabling this can help during migrations, if users are changing UPN.", + "docsDescription": "Sets the Home Realm Discovery policy AlternateIdLogin setting to enable or disable using email as an alternate sign-in ID.", + "executiveText": "Controls whether users can sign in with email as an alternate identifier, allowing organizations to align sign-in behavior with their identity strategy and reduce authentication ambiguity.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.EmailAsAlternateLoginId.Enabled", + "label": "Enable Email as Alternate Login ID", + "defaultValue": true + } + ], + "label": "Configure Email as alternate login ID", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-06-03", + "powershellEquivalent": "Invoke-MgGraphRequest https://graph.microsoft.com/v1.0/policies/homeRealmDiscoveryPolicies/", + "recommendedBy": ["CIPP"] + }, { "name": "standards.Disablex509Certificate", "cat": "Entra (AAD) Standards", @@ -1524,7 +1790,8 @@ { "name": "standards.AppManagementPolicy", "cat": "Entra (AAD) Standards", - "tag": [], + "tag": ["CIS M365 7.0.0 (5.1.5.3)", "CIS M365 7.0.0 (5.1.5.4)", "CIS M365 7.0.0 (5.1.5.5)", "CIS M365 7.0.0 (5.1.5.6)"], + "appliesToTest": ["CIS_5_1_5_3", "CIS_5_1_5_4", "CIS_5_1_5_5", "CIS_5_1_5_6"], "helpText": "Configures the default app management policy to control application and service principal credential restrictions such as password and key credential lifetimes.", "docsDescription": "Configures the default app management policy to control application and service principal credential restrictions. This includes password addition restrictions, custom password addition, symmetric key addition, and credential lifetime limits for both applications and service principals.", "executiveText": "Enforces credential restrictions on application registrations and service principals to limit how secrets and certificates are created and how long they remain valid. This reduces the risk of long-lived or unmanaged credentials being used to access your tenant.", @@ -1577,7 +1844,7 @@ { "name": "standards.OutBoundSpamAlert", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (2.1.6)"], + "tag": ["CIS M365 7.0.0 (2.1.6)"], "appliesToTest": ["CIS_2_1_6"], "helpText": "Set the Outbound Spam Alert e-mail address", "docsDescription": "Sets the e-mail address to which outbound spam alerts are sent.", @@ -1855,13 +2122,8 @@ { "name": "standards.SpoofWarn", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (6.2.3)"], - "appliesToTest": [ - "CISAMSEXO71", - "CIS_6_2_3", - "ORCA111", - "ORCA240" - ], + "tag": ["CIS M365 7.0.0 (6.2.3)"], + "appliesToTest": ["CISAMSEXO71", "CIS_6_2_3", "ORCA111", "ORCA240"], "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", "executiveText": "Displays visual warnings in Outlook when emails come from external senders, helping employees identify potentially suspicious messages and reducing the risk of phishing attacks. This security feature makes it easier for staff to distinguish between internal and external communications.", @@ -1902,7 +2164,7 @@ { "name": "standards.EnableMailTips", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (6.5.2)", "exo_mailtipsenabled"], + "tag": ["CIS M365 7.0.0 (6.5.2)", "exo_mailtipsenabled"], "appliesToTest": ["CIS_6_5_2"], "helpText": "Enables all MailTips in Outlook. MailTips are the notifications Outlook and Outlook on the web shows when an email you create, meets some requirements", "executiveText": "Enables helpful notifications in Outlook that warn users about potential email issues, such as sending to large groups, external recipients, or invalid addresses. This reduces email mistakes and improves communication efficiency by providing real-time guidance to employees.", @@ -1980,11 +2242,8 @@ { "name": "standards.RotateDKIM", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (2.1.9)", "SMB1001 (2.12)"], - "appliesToTest": [ - "CIS_2_1_9", - "SMB1001_2_12" - ], + "tag": ["CIS M365 7.0.0 (2.1.9)", "SMB1001 (2.12)"], + "appliesToTest": ["CIS_2_1_9", "SMB1001_2_12"], "helpText": "Rotate DKIM keys that are 1024 bit to 2048 bit", "executiveText": "Upgrades email security by replacing older 1024-bit encryption keys with stronger 2048-bit keys for email authentication. This improves the organization's email security posture and helps prevent email spoofing and tampering, maintaining trust with email recipients.", "addedComponent": [], @@ -2037,14 +2296,8 @@ { "name": "standards.AddDKIM", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (2.1.9)", "SMB1001 (2.12)"], - "appliesToTest": [ - "CISAMSEXO31", - "CIS_2_1_9", - "ORCA108", - "ORCA108_1", - "SMB1001_2_12" - ], + "tag": ["CIS M365 7.0.0 (2.1.9)", "SMB1001 (2.12)"], + "appliesToTest": ["CISAMSEXO31", "CIS_2_1_9", "ORCA108", "ORCA108_1", "SMB1001_2_12"], "helpText": "Enables DKIM for all domains that currently support it", "executiveText": "Enables email authentication technology that digitally signs outgoing emails to verify they actually came from your organization. This prevents email spoofing, improves email deliverability, and protects the company's reputation by ensuring recipients can trust emails from your domains.", "addedComponent": [], @@ -2065,11 +2318,8 @@ { "name": "standards.AddDMARCToMOERA", "cat": "Global Standards", - "tag": ["CIS M365 6.0.1 (2.1.10)", "Security", "PhishingProtection", "SMB1001 (2.12)"], - "appliesToTest": [ - "CIS_2_1_10", - "SMB1001_2_12" - ], + "tag": ["CIS M365 7.0.0 (2.1.10)", "Security", "PhishingProtection", "SMB1001 (2.12)"], + "appliesToTest": ["CIS_2_1_10", "SMB1001_2_12"], "helpText": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default value is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "docsDescription": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. Adds a DMARC record to MOERA (onmicrosoft.com) domains. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default record is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", "executiveText": "Implements advanced email security for Microsoft's default domain names (onmicrosoft.com) to prevent criminals from impersonating your organization. This blocks fraudulent emails that could damage your company's reputation and protects partners and customers from phishing attacks using your domain names.", @@ -2099,20 +2349,15 @@ "name": "standards.EnableMailboxAuditing", "cat": "Exchange Standards", "tag": [ - "CIS M365 6.0.1 (6.1.1)", - "CIS M365 6.0.1 (6.1.2)", - "CIS M365 6.0.1 (6.1.3)", + "CIS M365 7.0.0 (6.1.1)", + "CIS M365 7.0.0 (6.1.2)", + "CIS M365 7.0.0 (6.1.3)", "exo_mailboxaudit", "Essential 8 (1509)", "Essential 8 (1683)", "NIST CSF 2.0 (DE.CM-09)" ], - "appliesToTest": [ - "CISAMSEXO131", - "CIS_6_1_1", - "CIS_6_1_2", - "CIS_6_1_3" - ], + "appliesToTest": ["CISAMSEXO131", "CIS_6_1_1", "CIS_6_1_2", "CIS_6_1_3"], "helpText": "Enables Mailbox auditing for all mailboxes and on tenant level. Disables audit bypass on all mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "docsDescription": "Enables mailbox auditing on tenant level and for all mailboxes. Disables audit bypass on all mailboxes. By default Microsoft does not enable mailbox auditing for Resource Mailboxes, Public Folder Mailboxes and DiscoverySearch Mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "executiveText": "Enables comprehensive logging of all email access and modifications across all employee mailboxes, providing detailed audit trails for security investigations and compliance requirements. This helps detect unauthorized access, data breaches, and supports regulatory compliance efforts.", @@ -2313,7 +2558,7 @@ { "name": "standards.EXOOutboundSpamLimits", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (2.1.15)"], + "tag": ["CIS M365 7.0.0 (2.1.15)"], "appliesToTest": ["CIS_2_1_15"], "helpText": "Configures the outbound spam recipient limits (external per hour, internal per hour, per day) and the action to take when a limit is reached. The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one. ", "docsDescription": "Configures the Exchange Online outbound spam recipient limits for external per hour, internal per hour, and per day, along with the action to take (e.g., BlockUser, Alert) when these limits are exceeded. This helps prevent abuse and manage email flow. Microsoft's recommendations can be found [here.](https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#eop-outbound-spam-policy-settings) The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one.", @@ -2382,12 +2627,8 @@ { "name": "standards.DisableExternalCalendarSharing", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (1.3.3)", "exo_individualsharing"], - "appliesToTest": [ - "CISAMSEXO62", - "CIS_1_3_3", - "ZTNA21803" - ], + "tag": ["CIS M365 7.0.0 (1.3.3)", "exo_individualsharing"], + "appliesToTest": ["CISAMSEXO62", "CIS_1_3_3", "ZTNA21803"], "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", "executiveText": "Prevents employees from sharing their calendars with external parties, protecting sensitive meeting information and internal schedules from unauthorized access. This security measure helps maintain confidentiality of business activities while still allowing internal collaboration.", @@ -2425,11 +2666,8 @@ { "name": "standards.DisableAdditionalStorageProviders", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (6.5.3)", "exo_storageproviderrestricted"], - "appliesToTest": [ - "CIS_6_5_3", - "ZTNA21817" - ], + "tag": ["CIS M365 7.0.0 (6.5.3)", "exo_storageproviderrestricted"], + "appliesToTest": ["CIS_6_5_3", "ZTNA21817"], "helpText": "Disables the ability for users to open files in Outlook on the Web, from other providers such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.", "docsDescription": "Disables additional storage providers in OWA. This is to prevent users from using personal storage providers like Dropbox, Google Drive, etc. Usually this has little user impact.", "executiveText": "Prevents employees from accessing personal cloud storage services like Dropbox or Google Drive through Outlook on the web, reducing data security risks and ensuring company information stays within approved corporate systems. This helps maintain data governance and prevents accidental data leaks.", @@ -2451,7 +2689,7 @@ { "name": "standards.AntiSpamSafeList", "cat": "Defender Standards", - "tag": ["CIS M365 6.0.1 (2.1.13)"], + "tag": ["CIS M365 7.0.0 (2.1.13)"], "appliesToTest": ["CIS_2_1_13"], "helpText": "Sets the anti-spam connection filter policy option 'safe list' in Defender.", "docsDescription": "Sets [Microsoft's built-in 'safe list'](https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps#-enablesafelist) in the anti-spam connection filter policy, rather than setting a custom safe/block list of IPs.", @@ -2533,7 +2771,7 @@ { "name": "standards.Bookings", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (1.3.9)"], + "tag": ["CIS M365 7.0.0 (1.3.9)"], "appliesToTest": ["CIS_1_3_9"], "helpText": "Sets the state of Bookings on the tenant. Bookings is a scheduling tool that allows users to book appointments with others both internal and external.", "docsDescription": "", @@ -2567,7 +2805,7 @@ { "name": "standards.EXODirectSend", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (6.5.5)"], + "tag": ["CIS M365 7.0.0 (6.5.5)"], "appliesToTest": ["CIS_6_5_5"], "helpText": "Sets the state of Direct Send in Exchange Online. Direct Send allows applications to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication.", "docsDescription": "Controls whether applications can use Direct Send to send emails directly to Exchange Online mailboxes as the tenants domains, without requiring authentication. A detailed explanation from Microsoft can be found [here.](https://learn.microsoft.com/en-us/exchange/mail-flow-best-practices/how-to-set-up-a-multifunction-device-or-application-to-send-email-using-microsoft-365-or-office-365)", @@ -2596,15 +2834,12 @@ "name": "standards.DisableOutlookAddins", "cat": "Exchange Standards", "tag": [ - "CIS M365 6.0.1 (6.3.1)", + "CIS M365 7.0.0 (6.3.1)", "exo_outlookaddins", "NIST CSF 2.0 (PR.AA-05)", "NIST CSF 2.0 (PR.PS-05)" ], - "appliesToTest": [ - "CIS_6_3_1", - "ZTNA21817" - ], + "appliesToTest": ["CIS_6_3_1", "ZTNA21817"], "helpText": "Disables the ability for users to install add-ins in Outlook. This is to prevent users from installing malicious add-ins.", "docsDescription": "Disables users from being able to install add-ins in Outlook. Only admins are able to approve add-ins for the users. This is done to reduce the threat surface for data exfiltration.", "executiveText": "Prevents employees from installing third-party add-ins in Outlook without administrative approval, reducing security risks from potentially malicious extensions. This ensures only vetted and approved tools can access company email data while maintaining centralized control over email functionality.", @@ -2659,12 +2894,46 @@ "name": "standards.DelegateSentItems.IncludeUserMailboxes" } ], - "label": "Set mailbox Sent Items delegation (Sent items for shared mailboxes)", + "label": "Set mailbox Sent Items delegation (Sent items for shared mailboxes)", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2021-11-16", + "powershellEquivalent": "Set-Mailbox", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, + { + "name": "standards.SendFromAlias", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Enables or disables the ability for users to send from their alias addresses.", + "docsDescription": "Allows users to change the 'from' address to any set in their Azure AD Profile.", + "executiveText": "Allows employees to send emails from their alternative email addresses (aliases) rather than just their primary address. This is useful for employees who manage multiple roles or departments, enabling them to send emails from the most appropriate address for the context.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.SendFromAlias.state", + "options": [ + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } + ] + } + ], + "label": "Set Send from alias state", "impact": "Medium Impact", "impactColour": "warning", - "addedDate": "2021-11-16", + "addedDate": "2022-05-25", "powershellEquivalent": "Set-Mailbox", - "recommendedBy": [], + "recommendedBy": ["CIPP"], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", "EXCHANGE_S_ENTERPRISE", @@ -2674,31 +2943,31 @@ ] }, { - "name": "standards.SendFromAlias", + "name": "standards.DlpViaDcsEnabled", "cat": "Exchange Standards", "tag": [], - "helpText": "Enables or disables the ability for users to send from their alias addresses.", - "docsDescription": "Allows users to change the 'from' address to any set in their Azure AD Profile.", - "executiveText": "Allows employees to send emails from their alternative email addresses (aliases) rather than just their primary address. This is useful for employees who manage multiple roles or departments, enabling them to send emails from the most appropriate address for the context.", + "helpText": "Sets whether Outlook on the web uses Data Classification Services for DLP evaluation. See [Microsoft's policy tip reference](https://learn.microsoft.com/en-us/purview/dlp-ol365-win32-policy-tips#sensitive-information-types-that-support-policy-tips-for-outlook-perpetual-users).", + "docsDescription": "Configures whether Outlook on the web uses Data Classification Services (DCS)-based Data Loss Prevention (DLP) policy evaluation instead of Exchange-based evaluation. Review DLP policies before enabling this setting, as some legacy Exchange-based predicates are not supported with DCS-based evaluation. See [Microsoft's policy tip reference](https://learn.microsoft.com/en-us/purview/dlp-ol365-win32-policy-tips#sensitive-information-types-that-support-policy-tips-for-outlook-perpetual-users).", + "executiveText": "Improves how Outlook on the web applies Data Loss Prevention policies, giving users clearer guidance when sensitive information may be shared and helping reduce accidental data exposure.", "addedComponent": [ { "type": "autoComplete", "multiple": false, "creatable": false, "label": "Select value", - "name": "standards.SendFromAlias.state", + "name": "standards.DlpViaDcsEnabled.state", "options": [ { "label": "Enabled", "value": "true" }, { "label": "Disabled", "value": "false" } ] } ], - "label": "Set Send from alias state", + "label": "Set OWA DLP evaluation via DCS", "impact": "Medium Impact", "impactColour": "warning", - "addedDate": "2022-05-25", - "powershellEquivalent": "Set-Mailbox", - "recommendedBy": ["CIPP"], + "addedDate": "2026-05-20", + "powershellEquivalent": "Set-OrganizationConfig -DlpViaDcsEnabled", + "recommendedBy": [], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", "EXCHANGE_S_ENTERPRISE", @@ -2710,7 +2979,7 @@ { "name": "standards.UserSubmissions", "cat": "Exchange Standards", - "tag": ["CIS M365 6.0.1 (8.6.1)"], + "tag": ["CIS M365 7.0.0 (8.6.1)"], "appliesToTest": ["CIS_8_6_1"], "helpText": "Set the state of the spam submission button in Outlook", "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish.", @@ -2751,15 +3020,12 @@ "name": "standards.DisableSharedMailbox", "cat": "Exchange Standards", "tag": [ - "CIS M365 6.0.1 (1.2.2)", + "CIS M365 7.0.0 (1.2.2)", "CISA (MS.AAD.10.1v1)", "NIST CSF 2.0 (PR.AA-01)", "SMB1001 (2.3)" ], - "appliesToTest": [ - "CIS_1_2_2", - "SMB1001_2_3" - ], + "appliesToTest": ["CIS_1_2_2", "SMB1001_2_3"], "helpText": "Blocks login for all accounts that are marked as a shared mailbox. This is Microsoft best practice to prevent direct logons to shared mailboxes.", "docsDescription": "Shared mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for shared mailboxes. It would be a good idea to review the sign-in reports to establish potential impact.", "executiveText": "Prevents direct login to shared mailbox accounts (like info@company.com), ensuring they can only be accessed through authorized users' accounts. This security measure eliminates the risk of shared passwords and unauthorized access while maintaining proper access control and audit trails.", @@ -2798,7 +3064,7 @@ "name": "standards.EXODisableAutoForwarding", "cat": "Exchange Standards", "tag": [ - "CIS M365 6.0.1 (6.2.1)", + "CIS M365 7.0.0 (6.2.1)", "mdo_autoforwardingmode", "mdo_blockmailforward", "CISA (MS.EXO.4.1v1)", @@ -2955,7 +3221,7 @@ "name": "standards.SafeLinksPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 6.0.1 (2.1.1)", + "CIS M365 7.0.0 (2.1.1)", "mdo_safelinksforemail", "mdo_safelinksforOfficeApps", "NIST CSF 2.0 (DE.CM-09)" @@ -3039,7 +3305,7 @@ "mdo_spam_notifications_only_for_admins", "mdo_antiphishingpolicies", "mdo_phishthresholdlevel", - "CIS M365 6.0.1 (2.1.7)", + "CIS M365 7.0.0 (2.1.7)", "NIST CSF 2.0 (DE.CM-09)" ], "appliesToTest": [ @@ -3233,18 +3499,13 @@ "name": "standards.SafeAttachmentPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 6.0.1 (2.1.4)", + "CIS M365 7.0.0 (2.1.4)", "mdo_safedocuments", "mdo_commonattachmentsfilter", "mdo_safeattachmentpolicy", "NIST CSF 2.0 (DE.CM-09)" ], - "appliesToTest": [ - "CIS_2_1_4", - "ORCA158", - "ORCA189", - "ORCA227" - ], + "appliesToTest": ["CIS_2_1_4", "ORCA158", "ORCA189", "ORCA227"], "helpText": "This creates a Safe Attachment policy", "addedComponent": [ { @@ -3310,7 +3571,7 @@ { "name": "standards.AtpPolicyForO365", "cat": "Defender Standards", - "tag": ["CIS M365 6.0.1 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], + "tag": ["CIS M365 7.0.0 (2.1.5)", "NIST CSF 2.0 (DE.CM-09)"], "appliesToTest": ["CIS_2_1_5", "ORCA225"], "helpText": "This creates a Atp policy that enables Defender for Office 365 for SharePoint, OneDrive and Microsoft Teams.", "addedComponent": [ @@ -3341,10 +3602,7 @@ "name": "standards.PhishingSimulations", "cat": "Defender Standards", "tag": ["SMB1001 (1.11)", "SMB1001 (5.1)"], - "appliesToTest": [ - "SMB1001_1_11", - "SMB1001_5_1" - ], + "appliesToTest": ["SMB1001_1_11", "SMB1001_5_1"], "helpText": "This creates a phishing simulation policy that enables phishing simulations for the entire tenant.", "addedComponent": [ { @@ -3397,9 +3655,9 @@ "name": "standards.MalwareFilterPolicy", "cat": "Defender Standards", "tag": [ - "CIS M365 6.0.1 (2.1.2)", - "CIS M365 6.0.1 (2.1.3)", - "CIS M365 6.0.1 (2.1.11)", + "CIS M365 7.0.0 (2.1.2)", + "CIS M365 7.0.0 (2.1.3)", + "CIS M365 7.0.0 (2.1.11)", "mdo_zapspam", "mdo_zapphish", "mdo_zapmalware", @@ -4079,7 +4337,7 @@ { "name": "standards.IntuneComplianceSettings", "cat": "Intune Standards", - "tag": ["CIS M365 6.0.1 (4.1)"], + "tag": ["CIS M365 7.0.0 (4.1)"], "appliesToTest": ["CIS_4_1"], "helpText": "Sets the mark devices with no compliance policy assigned as compliance/non compliant and Compliance status validity period.", "executiveText": "Configures how the system treats devices that don't have specific compliance policies and sets how often devices must check in to maintain their compliance status. This ensures proper security oversight of all corporate devices and maintains current compliance information.", @@ -4100,7 +4358,7 @@ "type": "number", "name": "standards.IntuneComplianceSettings.deviceComplianceCheckinThresholdDays", "label": "Compliance status validity period (days)", - "defaultValue": 130, + "defaultValue": 120, "validators": { "min": { "value": 1, "message": "Minimum value is 1" }, "max": { "value": 120, "message": "Maximum value is 120" } @@ -4151,7 +4409,7 @@ { "name": "standards.DefaultPlatformRestrictions", "cat": "Intune Standards", - "tag": ["CIS M365 6.0.1 (4.2)", "CISA (MS.AAD.19.1v1)"], + "tag": ["CIS M365 7.0.0 (4.2)", "CISA (MS.AAD.19.1v1)"], "appliesToTest": ["CIS_4_2"], "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", "executiveText": "Controls which types of devices (iOS, Android, Windows, macOS) and ownership models (corporate vs. personal) can be enrolled in the company's device management system. This helps maintain security standards while supporting necessary business device types and usage scenarios.", @@ -4358,6 +4616,28 @@ "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.remotePassportEnabled", "label": "Allow phone sign-in", "default": true + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.enhancedSignInSecurity", + "label": "Enable enhanced sign-in security", + "multiple": false, + "options": [ + { "label": "Not configured", "value": "0" }, + { "label": "Enabled on capable hardware", "value": "1" }, + { "label": "Disabled on all systems", "value": "2" } + ] + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.securityKeyForSignIn", + "label": "Use security keys for sign-in", + "multiple": false, + "options": [ + { "label": "Not configured", "value": "notConfigured" }, + { "label": "Enabled", "value": "enabled" }, + { "label": "Disabled", "value": "disabled" } + ] } ], "label": "Windows Hello for Business enrollment configuration", @@ -4371,13 +4651,8 @@ { "name": "standards.intuneDeviceReg", "cat": "Intune Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.2)", "CISA (MS.AAD.17.1v1)"], - "appliesToTest": [ - "CIS_5_1_4_2", - "ZTNA21801", - "ZTNA21802", - "ZTNA21837" - ], + "tag": ["CIS M365 7.0.0 (5.1.4.2)", "CISA (MS.AAD.17.1v1)"], + "appliesToTest": ["CIS_5_1_4_2", "ZTNA21801", "ZTNA21802", "ZTNA21837"], "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", "executiveText": "Limits how many devices each employee can register for corporate access, preventing excessive device proliferation while accommodating legitimate business needs. This helps maintain security oversight and prevents potential abuse of device registration privileges.", "addedComponent": [ @@ -4399,12 +4674,8 @@ { "name": "standards.intuneDeviceRegLocalAdmins", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.3)", "CIS M365 6.0.1 (5.1.4.4)", "SMB1001 (2.2)"], - "appliesToTest": [ - "CIS_5_1_4_3", - "CIS_5_1_4_4", - "SMB1001_2_2" - ], + "tag": ["CIS M365 7.0.0 (5.1.4.3)", "CIS M365 7.0.0 (5.1.4.4)", "SMB1001 (2.2)"], + "appliesToTest": ["CIS_5_1_4_3", "CIS_5_1_4_4", "SMB1001_2_2"], "helpText": "Controls whether users who register Microsoft Entra joined devices are granted local administrator rights on those devices and if Global Administrators are added as local admins.", "docsDescription": "Configures the Device Registration Policy local administrator behavior for registering users. When enabled, users who register devices are not granted local administrator rights, you can also configure if Global Administrators are added as local admins.", "executiveText": "Controls whether employees who enroll devices automatically receive local administrator access. Disabling registering-user admin rights follows least-privilege principles and reduces security risk from over-privileged endpoints.", @@ -4432,11 +4703,8 @@ { "name": "standards.intuneRestrictUserDeviceRegistration", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.4.1)", "SMB1001 (2.8)"], - "appliesToTest": [ - "CIS_5_1_4_1", - "SMB1001_2_8" - ], + "tag": [], + "appliesToTest": [], "helpText": "Controls whether users can register devices with Entra.", "docsDescription": "Configures whether users can register devices with Entra. When disabled, users are unable to register devices with Entra.", "executiveText": "Controls whether employees can register their devices for corporate access. Disabling user device registration prevents unauthorized or unmanaged devices from connecting to company resources, enhancing overall security posture.", @@ -4455,15 +4723,34 @@ "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", "recommendedBy": [] }, + { + "name": "standards.intuneRestrictUserDeviceJoin", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (5.1.4.1)", "SMB1001 (2.8)"], + "appliesToTest": ["CIS_5_1_4_1", "SMB1001_2_8"], + "helpText": "Controls whether users can join devices to Entra.", + "docsDescription": "Configures whether users can join devices to Entra. When disabled, users are unable to Entra-join devices, which prevents them from creating new Entra-joined (cloud-managed) device identities.", + "executiveText": "Controls whether employees can join their devices to the corporate Entra directory. Disabling user device join prevents unauthorized or unmanaged devices from becoming corporate-managed identities, enhancing overall security posture.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.intuneRestrictUserDeviceJoin.disableUserDeviceJoin", + "label": "Disable users from joining devices", + "defaultValue": true + } + ], + "label": "Configure user restriction for Entra device join", + "impact": "High Impact", + "impactColour": "warning", + "addedDate": "2026-05-15", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "recommendedBy": [] + }, { "name": "standards.intuneRequireMFA", "cat": "Intune Standards", "tag": [], - "appliesToTest": [ - "ZTNA21782", - "ZTNA21796", - "ZTNA21872" - ], + "appliesToTest": ["ZTNA21782", "ZTNA21796", "ZTNA21872"], "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", "executiveText": "Requires employees to use multi-factor authentication when registering devices for corporate access, adding an extra security layer to prevent unauthorized device enrollment. This helps ensure only legitimate users can connect their devices to company systems.", "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", @@ -4588,7 +4875,7 @@ { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.2)"], + "tag": ["CIS M365 7.0.0 (7.2.2)"], "appliesToTest": ["CIS_7_2_2"], "helpText": "Ensure SharePoint and OneDrive integration with Azure AD B2B is enabled", "executiveText": "Enables secure collaboration with external partners through SharePoint and OneDrive by integrating with Azure B2B guest access. This allows controlled sharing with external organizations while maintaining security oversight and proper access management.", @@ -4611,15 +4898,8 @@ { "name": "standards.SPDisallowInfectedFiles", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.3.1)", - "CISA (MS.SPO.3.1v1)", - "NIST CSF 2.0 (DE.CM-09)" - ], - "appliesToTest": [ - "CIS_7_3_1", - "ZTNA21817" - ], + "tag": ["CIS M365 7.0.0 (7.3.1)", "CISA (MS.SPO.3.1v1)", "NIST CSF 2.0 (DE.CM-09)"], + "appliesToTest": ["CIS_7_3_1", "ZTNA21817"], "helpText": "Ensure Office 365 SharePoint infected files are disallowed for download", "executiveText": "Prevents employees from downloading files that have been identified as containing malware or viruses from SharePoint and OneDrive. This security measure protects against malware distribution through file sharing while maintaining access to clean, safe documents.", "addedComponent": [], @@ -4685,13 +4965,8 @@ { "name": "standards.SPExternalUserExpiration", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.9)", "CISA (MS.SPO.1.5v1)"], - "appliesToTest": [ - "CIS_7_2_9", - "ZTNA21803", - "ZTNA21804", - "ZTNA21858" - ], + "tag": ["CIS M365 7.0.0 (7.2.9)", "CISA (MS.SPO.1.5v1)"], + "appliesToTest": ["CIS_7_2_9", "ZTNA21803", "ZTNA21804", "ZTNA21858"], "helpText": "Ensure guest access to a site or OneDrive will expire automatically", "executiveText": "Automatically expires external user access to SharePoint sites and OneDrive after a specified period, reducing security risks from forgotten or unnecessary guest accounts. This ensures external access is regularly reviewed and maintained only when actively needed.", "addedComponent": [ @@ -4724,12 +4999,8 @@ { "name": "standards.SPEmailAttestation", "cat": "SharePoint Standards", - "tag": ["CIS M365 6.0.1 (7.2.10)", "CISA (MS.SPO.1.6v1)"], - "appliesToTest": [ - "CIS_7_2_10", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.10)", "CISA (MS.SPO.1.6v1)"], + "appliesToTest": ["CIS_7_2_10", "ZTNA21803", "ZTNA21804"], "helpText": "Ensure re-authentication with verification code is restricted", "executiveText": "Requires external users to periodically re-verify their identity through email verification codes when accessing SharePoint resources, adding an extra security layer for external collaboration. This helps ensure continued legitimacy of external access over time.", "addedComponent": [ @@ -4762,17 +5033,8 @@ { "name": "standards.DefaultSharingLink", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.2.7)", - "CIS M365 6.0.1 (7.2.11)", - "CISA (MS.SPO.1.4v1)" - ], - "appliesToTest": [ - "CIS_7_2_11", - "CIS_7_2_7", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.7)", "CIS M365 7.0.0 (7.2.11)", "CISA (MS.SPO.1.4v1)"], + "appliesToTest": ["CIS_7_2_11", "CIS_7_2_7", "ZTNA21803", "ZTNA21804"], "helpText": "Configure the SharePoint default sharing link type and permission. This setting controls both the type of sharing link created by default and the permission level assigned to those links.", "docsDescription": "Sets the default sharing link type (Direct or Internal) and permission (View) in SharePoint and OneDrive. Direct sharing means links only work for specific people, while Internal sharing means links work for anyone in the organization. Setting the view permission as the default ensures that users must deliberately select the edit permission when sharing a link, reducing the risk of unintentionally granting edit privileges.", "executiveText": "Configures SharePoint default sharing links to implement the principle of least privilege for document sharing. This security measure reduces the risk of accidental data modification while maintaining collaboration functionality, requiring users to explicitly select Edit permissions when necessary. The sharing type setting controls whether links are restricted to specific recipients or available to the entire organization. This reduces the risk of accidental data exposure through link sharing.", @@ -4877,16 +5139,12 @@ "name": "standards.DisableSharePointLegacyAuth", "cat": "SharePoint Standards", "tag": [ - "CIS M365 6.0.1 (7.2.1)", + "CIS M365 7.0.0 (7.2.1)", "spo_legacy_auth", "CISA (MS.AAD.3.1v1)", "NIST CSF 2.0 (PR.IR-01)" ], - "appliesToTest": [ - "CIS_7_2_1", - "ZTNA21776", - "ZTNA21797" - ], + "appliesToTest": ["CIS_7_2_1", "ZTNA21776", "ZTNA21797"], "helpText": "Disables the ability to authenticate with SharePoint using legacy authentication methods. Any applications that use legacy authentication will need to be updated to use modern authentication.", "docsDescription": "Disables the ability for users and applications to access SharePoint via legacy basic authentication. This will likely not have any user impact, but will block systems/applications depending on basic auth or the SharePointOnlineCredentials class.", "executiveText": "Disables outdated authentication methods for SharePoint access, forcing applications and users to use modern, more secure authentication protocols. This significantly improves security by eliminating vulnerable authentication pathways while requiring updates to older applications.", @@ -4910,17 +5168,12 @@ "name": "standards.sharingCapability", "cat": "SharePoint Standards", "tag": [ - "CIS M365 6.0.1 (7.2.3)", - "CIS M365 6.0.1 (7.2.4)", + "CIS M365 7.0.0 (7.2.3)", + "CIS M365 7.0.0 (7.2.4)", "CISA (MS.AAD.14.1v1)", "CISA (MS.SPO.1.1v1)" ], - "appliesToTest": [ - "CIS_7_2_3", - "CIS_7_2_4", - "ZTNA21803", - "ZTNA21804" - ], + "appliesToTest": ["CIS_7_2_3", "CIS_7_2_4", "ZTNA21803", "ZTNA21804"], "helpText": "Sets the default sharing level for OneDrive and SharePoint. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Defines the organization's default policy for sharing files and folders in SharePoint and OneDrive, balancing collaboration needs with security requirements. This fundamental setting determines whether employees can share with external users, anonymous links, or only internal colleagues.", "addedComponent": [ @@ -4967,16 +5220,8 @@ { "name": "standards.DisableReshare", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.2.5)", - "CISA (MS.AAD.14.2v1)", - "CISA (MS.SPO.1.2v1)" - ], - "appliesToTest": [ - "CIS_7_2_5", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.5)", "CISA (MS.AAD.14.2v1)", "CISA (MS.SPO.1.2v1)"], + "appliesToTest": ["CIS_7_2_5", "ZTNA21803", "ZTNA21804"], "helpText": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access", "docsDescription": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access. This is a tenant wide setting and overrules any settings set on the site level", "executiveText": "Prevents external users from sharing company documents with additional people, maintaining control over document distribution and preventing unauthorized access expansion. This security measure ensures that external sharing remains within intended boundaries set by internal employees.", @@ -5073,15 +5318,8 @@ { "name": "standards.unmanagedSync", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.3.2)", - "CISA (MS.SPO.2.1v1)", - "NIST CSF 2.0 (PR.AA-05)" - ], - "appliesToTest": [ - "CIS_7_3_2", - "ZTNA24824" - ], + "tag": ["CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"], + "appliesToTest": ["ZTNA24824"], "helpText": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect.", "docsDescription": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect. 0 = Allow Access, 1 = Allow limited, web-only access, 2 = Block access. All information about this can be found in Microsofts documentation [here.](https://learn.microsoft.com/en-us/sharepoint/control-access-from-unmanaged-devices)", "executiveText": "Restricts access to company files from personal or unmanaged devices, ensuring corporate data can only be accessed from properly secured and monitored devices. This critical security control prevents data leaks while allowing controlled access through web browsers when necessary.", @@ -5110,16 +5348,8 @@ { "name": "standards.sharingDomainRestriction", "cat": "SharePoint Standards", - "tag": [ - "CIS M365 6.0.1 (7.2.6)", - "CISA (MS.AAD.14.3v1)", - "CISA (MS.SPO.1.3v1)" - ], - "appliesToTest": [ - "CIS_7_2_6", - "ZTNA21803", - "ZTNA21804" - ], + "tag": ["CIS M365 7.0.0 (7.2.6)", "CISA (MS.AAD.14.3v1)", "CISA (MS.SPO.1.3v1)"], + "appliesToTest": ["CIS_7_2_6", "ZTNA21803", "ZTNA21804"], "helpText": "Restricts sharing to only users with the specified domain. This is useful for organizations that only want to share with their own domain.", "executiveText": "Controls which external domains employees can share files with, enabling secure collaboration with trusted partners while blocking sharing with unauthorized organizations. This targeted approach maintains necessary business relationships while preventing data exposure to unknown entities.", "addedComponent": [ @@ -5160,15 +5390,15 @@ "name": "standards.TeamsGlobalMeetingPolicy", "cat": "Teams Standards", "tag": [ - "CIS M365 6.0.1 (8.5.1)", - "CIS M365 6.0.1 (8.5.2)", - "CIS M365 6.0.1 (8.5.3)", - "CIS M365 6.0.1 (8.5.4)", - "CIS M365 6.0.1 (8.5.5)", - "CIS M365 6.0.1 (8.5.6)", - "CIS M365 6.0.1 (8.5.7)", - "CIS M365 6.0.1 (8.5.8)", - "CIS M365 6.0.1 (8.5.9)" + "CIS M365 7.0.0 (8.5.1)", + "CIS M365 7.0.0 (8.5.2)", + "CIS M365 7.0.0 (8.5.3)", + "CIS M365 7.0.0 (8.5.4)", + "CIS M365 7.0.0 (8.5.5)", + "CIS M365 7.0.0 (8.5.6)", + "CIS M365 7.0.0 (8.5.7)", + "CIS M365 7.0.0 (8.5.8)", + "CIS M365 7.0.0 (8.5.9)" ], "appliesToTest": [ "CIS_8_5_1", @@ -5302,7 +5532,7 @@ { "name": "standards.TeamsExternalChatWithAnyone", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.2.3)"], + "tag": ["CIS M365 7.0.0 (8.2.3)"], "appliesToTest": ["CIS_8_2_3"], "helpText": "Controls whether users can start Teams chats with any email address, inviting external recipients as guests via email.", "docsDescription": "Manages the Teams messaging policy setting UseB2BInvitesToAddExternalUsers. When enabled, users can start chats with any email address and recipients receive an invitation to join the chat as guests. Disabling the setting prevents these external email chats from being created, keeping conversations limited to internal users and approved guests.", @@ -5346,7 +5576,7 @@ "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowEmailIntoChannel $false", "recommendedBy": ["CIS"], - "tag": ["CIS M365 6.0.1 (8.1.2)"], + "tag": ["CIS M365 7.0.0 (8.1.2)"], "appliesToTest": ["CIS_8_1_2"], "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, @@ -5406,7 +5636,7 @@ { "name": "standards.TeamsExternalFileSharing", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.1.1)"], + "tag": ["CIS M365 7.0.0 (8.1.1)"], "appliesToTest": ["CIS_8_1_1"], "helpText": "Ensure external file sharing in Teams is enabled for only approved cloud storage services.", "executiveText": "Controls which external cloud storage services (like Google Drive, Dropbox, Box) employees can access through Teams, ensuring file sharing occurs only through approved and secure platforms. This helps maintain data governance while supporting necessary business integrations.", @@ -5477,11 +5707,8 @@ { "name": "standards.TeamsExternalAccessPolicy", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.2.1)", "CIS M365 6.0.1 (8.2.2)"], - "appliesToTest": [ - "CIS_8_2_1", - "CIS_8_2_2" - ], + "tag": ["CIS M365 7.0.0 (8.2.1)", "CIS M365 7.0.0 (8.2.2)"], + "appliesToTest": ["CIS_8_2_1", "CIS_8_2_2"], "helpText": "Sets the properties of the Global external access policy.", "docsDescription": "Sets the properties of the Global external access policy. External access policies determine whether or not your users can: 1) communicate with users who have Session Initiation Protocol (SIP) accounts with a federated organization; 2) communicate with users who are using custom applications built with Azure Communication Services; 3) access Skype for Business Server over the Internet, without having to log on to your internal network; 4) communicate with users who have SIP accounts with a public instant messaging (IM) provider such as Skype; and, 5) communicate with people who are using Teams with an account that's not managed by an organization.", "executiveText": "Defines the organization's policy for communicating with external users through Teams, including other organizations, Skype users, and unmanaged accounts. This fundamental setting determines the scope of external collaboration while maintaining security boundaries for business communications.", @@ -5508,7 +5735,7 @@ { "name": "standards.TeamsFederationConfiguration", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.2.1)"], + "tag": ["CIS M365 7.0.0 (8.2.1)"], "appliesToTest": ["CIS_8_2_1", "CIS_8_2_4"], "helpText": "Sets the properties of the Global federation configuration.", "docsDescription": "Sets the properties of the Global federation configuration. Federation configuration settings determine whether or not your users can communicate with users who have SIP accounts with a federated organization.", @@ -5584,7 +5811,7 @@ { "name": "standards.TeamsMessagingPolicy", "cat": "Teams Standards", - "tag": ["CIS M365 6.0.1 (8.6.1)"], + "tag": ["CIS M365 7.0.0 (8.6.1)"], "appliesToTest": ["CIS_8_6_1"], "helpText": "Sets the properties of the Global messaging policy.", "docsDescription": "Sets the properties of the Global messaging policy. Messaging policies control which chat and channel messaging features are available to users in Teams.", @@ -5831,6 +6058,110 @@ "recommendedBy": [], "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, + { + "name": "standards.DevicePrepProfile", + "cat": "Device Management Standards", + "tag": ["autopilot", "device_prep", "enrollment"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Creates and manages a Windows Autopilot Device Preparation profile for streamlined device enrollment.", + "docsDescription": "Deploys a Windows Autopilot Device Preparation profile through Intune configuration policies. This standard manages deployment mode, join type, account type, timeout, error messages, and optional device security group assignment. Optionally creates a new security group with the Intune Provisioning Client as owner.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.DevicePrepProfile.ProfileName", + "label": "Profile Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.ProfileDescription", + "label": "Profile Description", + "required": false + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.DeploymentType", + "label": "Deployment Type", + "options": [ + { "label": "Single user", "value": "0" }, + { "label": "Shared", "value": "1" } + ] + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.JoinType", + "label": "Join Type", + "options": [ + { "label": "Microsoft Entra join", "value": "0" }, + { "label": "Microsoft Entra hybrid join", "value": "1" } + ] + }, + { + "type": "select", + "multiple": false, + "name": "standards.DevicePrepProfile.AccountType", + "label": "Account Type", + "options": [ + { "label": "Standard user", "value": "0" }, + { "label": "Administrator", "value": "1" } + ] + }, + { + "type": "number", + "name": "standards.DevicePrepProfile.Timeout", + "label": "Timeout (minutes)", + "defaultValue": 60 + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.CustomErrorMessage", + "label": "Custom Error Message", + "required": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.AllowSkip", + "label": "Allow users to skip setup after failure", + "defaultValue": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.AllowDiagnostics", + "label": "Allow users to collect diagnostics", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.DevicePrepProfile.DeviceGroupName", + "label": "Device Security Group Name (wildcard match)", + "required": false + }, + { + "type": "switch", + "name": "standards.DevicePrepProfile.CreateNewGroup", + "label": "Create new group if group is not found", + "defaultValue": false + }, + { + "type": "radio", + "name": "standards.DevicePrepProfile.AssignTo", + "label": "Policy Assignment", + "options": [ + { "label": "Do not assign", "value": "none" }, + { "label": "All devices", "value": "AllDevices" }, + { "label": "All users and devices", "value": "AllDevicesAndUsers" } + ] + } + ], + "label": "Deploy Device Prep Profile", + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-05-25", + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, { "name": "standards.IntuneTemplate", "cat": "Templates", @@ -5959,6 +6290,18 @@ { "label": "Include - Assign to devices matching the filter", "value": "include" }, { "label": "Exclude - Assign to devices NOT matching the filter", "value": "exclude" } ] + }, + { + "type": "number", + "required": false, + "name": "levenshteinDistance", + "label": "Fuzzy Match Distance (0 = exact name match only, higher values allow replacing policies with similar names based on Levenshtein distance)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" } + }, + "warningThreshold": 5, + "warningMessage": "Warning: values above 5 can match unrelated policies. Use with caution." } ] }, @@ -6063,18 +6406,18 @@ "impact": "High Impact", "addedDate": "2023-12-30", "tag": [ - "CIS M365 6.0.1 (5.2.2.1)", - "CIS M365 6.0.1 (5.2.2.2)", - "CIS M365 6.0.1 (5.2.2.3)", - "CIS M365 6.0.1 (5.2.2.4)", - "CIS M365 6.0.1 (5.2.2.5)", - "CIS M365 6.0.1 (5.2.2.6)", - "CIS M365 6.0.1 (5.2.2.7)", - "CIS M365 6.0.1 (5.2.2.8)", - "CIS M365 6.0.1 (5.2.2.9)", - "CIS M365 6.0.1 (5.2.2.10)", - "CIS M365 6.0.1 (5.2.2.11)", - "CIS M365 6.0.1 (5.2.2.12)", + "CIS M365 7.0.0 (5.2.2.1)", + "CIS M365 7.0.0 (5.2.2.2)", + "CIS M365 7.0.0 (5.2.2.3)", + "CIS M365 7.0.0 (5.2.2.4)", + "CIS M365 7.0.0 (5.2.2.5)", + "CIS M365 7.0.0 (5.2.2.6)", + "CIS M365 7.0.0 (5.2.2.7)", + "CIS M365 7.0.0 (5.2.2.8)", + "CIS M365 7.0.0 (5.2.2.9)", + "CIS M365 7.0.0 (5.2.2.10)", + "CIS M365 7.0.0 (5.2.2.11)", + "CIS M365 7.0.0 (5.2.2.12)", "SMB1001 (2.5)", "SMB1001 (2.6)", "SMB1001 (2.8)", @@ -6117,6 +6460,8 @@ "type": "autoComplete", "name": "TemplateList", "multiple": false, + "required": false, + "creatable": false, "label": "Select Conditional Access Template", "api": { "url": "/api/ListCATemplates", @@ -6127,6 +6472,23 @@ "templateView": { "title": "Conditional Access Policy" } } }, + { + "type": "autoComplete", + "multiple": false, + "required": false, + "creatable": false, + "name": "TemplateList-Tags", + "label": "Or select a package of CA Templates", + "api": { + "queryKey": "ListCATemplates-tag-autocomplete", + "url": "/api/ListCATemplates?mode=Tag", + "labelField": "label", + "valueField": "value", + "addedField": { + "templates": "templates" + } + } + }, { "name": "state", "label": "What state should we deploy this template in?", @@ -6182,8 +6544,8 @@ "label": "Group Template", "multi": true, "cat": "Templates", - "tag": ["CIS M365 6.0.1 (5.1.3.1)"], - "appliesToTest": ["CIS_5_1_3_1"], + "tag": [], + "appliesToTest": [], "disabledFeatures": { "report": true, "warn": true, "remediate": false }, "impact": "Medium Impact", "addedDate": "2023-12-30", @@ -6199,11 +6561,115 @@ "labelField": "Displayname", "altLabelField": "displayName", "valueField": "GUID", - "queryKey": "ListGroupTemplates" + "queryKey": "ListGroupTemplates" + } + } + ], + "requiredCapabilities": ["EXCHANGE_S_STANDARD", "EXCHANGE_S_ENTERPRISE", "EXCHANGE_LITE"] + }, + { + "name": "standards.DlpCompliancePolicyTemplate", + "label": "DLP Compliance Policy Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "impact": "Medium Impact", + "addedDate": "2026-05-10", + "helpText": "Deploy Microsoft Purview DLP compliance policies from CIPP templates.", + "executiveText": "Deploys Data Loss Prevention policies from a standardized template library. Ensures consistent DLP coverage across tenants for sensitive data such as financial, identity, and regulated content.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "dlpCompliancePolicyTemplate", + "label": "Select DLP Compliance Policy Templates", + "api": { + "url": "/api/ListDlpCompliancePolicyTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListDlpCompliancePolicyTemplates" + } + } + ] + }, + { + "name": "standards.RetentionCompliancePolicyTemplate", + "label": "Retention Compliance Policy Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "impact": "Medium Impact", + "addedDate": "2026-05-10", + "helpText": "Deploy Microsoft Purview retention compliance policies from CIPP templates.", + "executiveText": "Deploys retention policies that govern how long content is preserved in Exchange, SharePoint, OneDrive, and Teams. Enforces consistent compliance retention across tenants for regulatory and legal hold needs.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "retentionCompliancePolicyTemplate", + "label": "Select Retention Compliance Policy Templates", + "api": { + "url": "/api/ListRetentionCompliancePolicyTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListRetentionCompliancePolicyTemplates" + } + } + ] + }, + { + "name": "standards.SensitivityLabelTemplate", + "label": "Sensitivity Label Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "impact": "Medium Impact", + "addedDate": "2026-05-10", + "helpText": "Deploy Microsoft Purview sensitivity labels from CIPP templates.", + "executiveText": "Deploys sensitivity labels for classification and protection of files, emails, and Microsoft 365 group content. Ensures consistent classification taxonomy and encryption settings across tenants.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "sensitivityLabelTemplate", + "label": "Select Sensitivity Label Templates", + "api": { + "url": "/api/ListSensitivityLabelTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListSensitivityLabelTemplates" + } + } + ] + }, + { + "name": "standards.SensitiveInfoTypeTemplate", + "label": "Sensitive Information Type Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "impact": "Low Impact", + "addedDate": "2026-05-10", + "helpText": "Deploy custom Microsoft Purview Sensitive Information Types from CIPP templates.", + "executiveText": "Deploys custom Sensitive Information Types so DLP policies can detect organization-specific identifiers — employee IDs, project codenames, internal account numbers — across tenants consistently.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "name": "sensitiveInfoTypeTemplate", + "label": "Select Sensitive Information Type Templates", + "api": { + "url": "/api/ListSensitiveInfoTypeTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListSensitiveInfoTypeTemplates" } } - ], - "requiredCapabilities": ["EXCHANGE_S_STANDARD", "EXCHANGE_S_ENTERPRISE", "EXCHANGE_LITE"] + ] }, { "name": "standards.AssignmentFilterTemplate", @@ -6472,13 +6938,6 @@ "placeholder": "YOUR-COMPANY", "required": false }, - { - "type": "textField", - "name": "standards.DeployCheckChromeExtension.companyURL", - "label": "Company URL", - "placeholder": "https://yourcompany.com", - "required": false - }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.productName", @@ -6726,7 +7185,7 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.ConnectWindows", - "label": "Connect Windows 10.0.15063+ to MDE", + "label": "Connect Windows 10.0.15063+ to MDE (Note: enabling this forces 'Block Windows if partner data unavailable' to on)", "defaultValue": false }, { @@ -6738,7 +7197,7 @@ { "type": "switch", "name": "standards.DefenderCompliancePolicy.windowsDeviceBlockedOnMissingPartnerData", - "label": "Block Windows if partner data unavailable", + "label": "Block Windows if partner data unavailable (Note: Microsoft enforces this to on when Connect Windows 10.0.15063+ to MDE is on)", "defaultValue": false }, { @@ -7009,10 +7468,10 @@ { "name": "standards.EnforcePrivateGroups", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (1.2.1)"], + "tag": ["CIS M365 7.0.0 (1.2.1)"], "appliesToTest": ["CIS_1_2_1"], "helpText": "Sets all public Microsoft 365 groups to private automatically. Groups can be excluded by display name keyword.", - "docsDescription": "Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 6.0.1 benchmark control 1.2.1.", + "docsDescription": "Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 7.0.0 benchmark control 1.2.1.", "executiveText": "Enforces private visibility on all Microsoft 365 groups to prevent unauthorised external access to group resources such as Teams, SharePoint sites, and Planner boards. Approved public groups can be excluded by name, ensuring governance while retaining flexibility for intentionally public collaboration spaces.", "addedComponent": [ { @@ -7043,7 +7502,7 @@ { "name": "standards.EmptyFilterIPAllowList", "cat": "Defender Standards", - "tag": ["CIS M365 6.0.1 (2.1.12)"], + "tag": ["CIS M365 7.0.0 (2.1.12)"], "appliesToTest": ["CIS_2_1_12"], "helpText": "Ensures the connection filter IP allow list is not used. IPs on this list bypass spam, spoof, and authentication checks.", "docsDescription": "IPs on the connection filter allow list bypass spam, spoof, and authentication checks. CIS recommends keeping this list empty to ensure all inbound email is properly scanned. This standard checks that the IPAllowList on the Default hosted connection filter policy is empty and can remediate by clearing it.", @@ -7066,10 +7525,10 @@ { "name": "standards.TeamsZAP", "cat": "Defender Standards", - "tag": ["CIS M365 6.0.1 (2.4.4)"], + "tag": ["CIS M365 7.0.0 (2.4.4)"], "appliesToTest": ["CIS_2_4_4"], "helpText": "Ensures Zero-hour auto purge (ZAP) is enabled for Microsoft Teams, automatically removing malicious messages after delivery.", - "docsDescription": "Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 6.0.1 benchmark control 2.4.4.", + "docsDescription": "Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 7.0.0 benchmark control 2.4.4.", "executiveText": "Enables Zero-hour auto purge for Microsoft Teams to automatically detect and remove malicious messages after delivery. This provides an additional layer of protection against phishing and malware that may bypass initial scanning, ensuring threats are neutralised even after they reach users.", "addedComponent": [], "label": "Ensure Zero-hour auto purge for Microsoft Teams is on", @@ -7089,7 +7548,7 @@ { "name": "standards.CollaborationDomainRestriction", "cat": "Entra (AAD) Standards", - "tag": ["CIS M365 6.0.1 (5.1.6.1)"], + "tag": ["CIS M365 7.0.0 (5.1.6.1)"], "appliesToTest": ["CIS_5_1_6_1"], "helpText": "Restricts B2B collaboration invitations to a specified list of allowed domains. If no domains are provided, the standard will alert and report on whether any domain restrictions are currently configured.", "docsDescription": "By default, Microsoft Entra ID allows collaboration invitations to be sent to any external domain. CIS recommends restricting B2B collaboration invitations to only approved domains to reduce the risk of data exfiltration and unauthorized access. This standard checks the B2B management policy for an allow list of domains and can remediate by setting the allowed domains list.", @@ -7109,5 +7568,419 @@ "addedDate": "2026-05-06", "powershellEquivalent": "Graph API PATCH https://graph.microsoft.com/beta/policies/b2bManagementPolicies/default", "recommendedBy": ["CIS"] + }, + { + "name": "standards.IntuneAppTemplateDeploy", + "cat": "Intune Standards", + "tag": [], + "helpText": "Deploys selected Intune application templates to the tenant. Supports WinGet/Store apps, Office apps, Chocolatey apps, Win32 script apps, and MSP apps.", + "docsDescription": "Uses CIPP Intune Application Templates to deploy applications across tenants as a standard. Each template can contain multiple applications of different types which will be queued for deployment.", + "executiveText": "Automatically deploys approved Intune applications across all managed tenants, ensuring consistent software availability and reducing manual deployment overhead. Supports WinGet, Office, Chocolatey, Win32, and MSP application types.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Application Templates", + "name": "standards.IntuneAppTemplateDeploy.templateIds", + "api": { + "url": "/api/ListAppTemplates", + "labelField": "displayName", + "valueField": "GUID", + "queryKey": "StdIntuneAppTemplateList" + } + } + ], + "label": "Deploy Intune Application Template", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-23", + "powershellEquivalent": "Graph API - /deviceAppManagement/mobileApps", + "recommendedBy": [] + }, + { + "name": "standards.AutopatchGroup", + "cat": "Intune Standards", + "tag": [], + "beta": true, + "deprecated": true, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Deploys a Windows Autopatch group with configurable deployment ring settings for quality updates, feature updates, Edge, and Office.", + "docsDescription": "Creates or updates a Windows Autopatch deployment group with Test and Last deployment rings. Configures quality update deferrals, feature update targeting, Edge and Office update channels per ring. Uses the Autopatch API proxy to manage the group configuration.", + "executiveText": "Configures Windows Autopatch deployment groups to manage update delivery across devices. Autopatch automates Windows quality updates, feature updates, Edge, and Office updates using deployment rings with configurable deferrals and deadlines.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.AutopatchGroup.GroupName", + "label": "Group Name", + "required": true, + "defaultValue": "Autopatch default group" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TargetOSVersion", + "label": "Target OS Version", + "required": true, + "options": [ + { "label": "Windows 11, version 24H2", "value": "Windows 11, version 24H2" }, + { "label": "Windows 11, version 25H2", "value": "Windows 11, version 25H2" } + ], + "defaultValue": "Windows 11, version 25H2" + }, + { + "type": "switch", + "name": "standards.AutopatchGroup.EnableDriverUpdate", + "label": "Enable Driver Updates", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.AutopatchGroup.InstallWin10OnWin11Ineligible", + "label": "Install latest Windows 10 on Windows 11 ineligible devices", + "defaultValue": false + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityDeferral", + "label": "Test Ring - Quality Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityDeadline", + "label": "Test Ring - Quality Update Deadline (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestQualityGracePeriod", + "label": "Test Ring - Quality Update Grace Period (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 7, "message": "Maximum value is 7" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestFeatureDeferral", + "label": "Test Ring - Feature Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestFeatureDeadline", + "label": "Test Ring - Feature Update Deadline (days)", + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TestEdgeChannel", + "label": "Test Ring - Edge Update Channel", + "options": [ + { "label": "Stable", "value": "Stable" }, + { "label": "Beta", "value": "Beta" }, + { "label": "Dev", "value": "Dev" } + ], + "defaultValue": "Beta" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.TestOfficeChannel", + "label": "Test Ring - Office Update Channel", + "options": [ + { "label": "Current", "value": "Current" }, + { "label": "Monthly Enterprise", "value": "MonthlyEnterprise" }, + { "label": "Semi-Annual Enterprise", "value": "SemiAnnual" } + ], + "defaultValue": "MonthlyEnterprise" + }, + { + "type": "number", + "name": "standards.AutopatchGroup.TestDnfDeferral", + "label": "Test Ring - Driver & Firmware Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityDeferral", + "label": "Last Ring - Quality Update Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityDeadline", + "label": "Last Ring - Quality Update Deadline (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastQualityGracePeriod", + "label": "Last Ring - Quality Update Grace Period (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 7, "message": "Maximum value is 7" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastFeatureDeferral", + "label": "Last Ring - Feature Update Deferral (days)", + "defaultValue": 0, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 365, "message": "Maximum value is 365" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastFeatureDeadline", + "label": "Last Ring - Feature Update Deadline (days)", + "defaultValue": 5, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.LastEdgeChannel", + "label": "Last Ring - Edge Update Channel", + "options": [ + { "label": "Stable", "value": "Stable" }, + { "label": "Beta", "value": "Beta" }, + { "label": "Dev", "value": "Dev" } + ], + "defaultValue": "Stable" + }, + { + "type": "select", + "multiple": false, + "name": "standards.AutopatchGroup.LastOfficeChannel", + "label": "Last Ring - Office Update Channel", + "options": [ + { "label": "Current", "value": "Current" }, + { "label": "Monthly Enterprise", "value": "MonthlyEnterprise" }, + { "label": "Semi-Annual Enterprise", "value": "SemiAnnual" } + ], + "defaultValue": "MonthlyEnterprise" + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastOfficeDeferral", + "label": "Last Ring - Office Update Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastOfficeDeadline", + "label": "Last Ring - Office Update Deadline (days)", + "defaultValue": 2, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + }, + { + "type": "number", + "name": "standards.AutopatchGroup.LastDnfDeferral", + "label": "Last Ring - Driver & Firmware Deferral (days)", + "defaultValue": 1, + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" }, + "max": { "value": 30, "message": "Maximum value is 30" } + } + } + ], + "label": "Deploy Windows Autopatch Group", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-27", + "powershellEquivalent": "Autopatch API - POST /api/autoPatch", + "recommendedBy": [] + }, + { + "name": "standards.FIDO2PasskeyProfiles", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Configures FIDO2 passkey profiles including AAGUID allowlists, attestation enforcement, and passkey types for the tenant.", + "docsDescription": "Manages FIDO2 passkey profiles on the tenant authentication methods policy. Allows defining passkey profiles that control which authenticators (hardware keys, password managers, Microsoft Authenticator) are permitted via AAGUID allowlists, whether attestation is enforced, and which passkey types (device-bound, synced, or both) are allowed. This enables MSPs to centrally deploy phishing-resistant MFA configurations across tenants.", + "executiveText": "Configures passkey (FIDO2) profiles that control which authenticators users can register for phishing-resistant MFA. Supports allowlisting specific hardware keys (e.g., YubiKey models), password managers (e.g., 1Password), and Microsoft Authenticator by AAGUID, with control over attestation enforcement and passkey types.", + "addedComponent": [ + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.PasskeyTypes", + "label": "Allowed Passkey Types", + "options": [ + { "label": "Device-bound only", "value": "deviceBound" }, + { "label": "Synced only", "value": "synced" }, + { "label": "Both device-bound and synced", "value": "deviceBound,synced" } + ], + "required": true + }, + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.AttestationEnforcement", + "label": "Attestation Enforcement", + "options": [ + { "label": "Disabled (required for synced passkeys)", "value": "disabled" }, + { "label": "Registration only", "value": "registrationOnly" } + ], + "required": true + }, + { + "type": "switch", + "name": "standards.FIDO2PasskeyProfiles.EnforceKeyRestrictions", + "label": "Enforce AAGUID Key Restrictions" + }, + { + "type": "select", + "multiple": false, + "name": "standards.FIDO2PasskeyProfiles.EnforcementType", + "label": "Key Restriction Type", + "options": [ + { "label": "Allow listed AAGUIDs only", "value": "allow" }, + { "label": "Block listed AAGUIDs", "value": "block" } + ] + }, + { + "type": "textField", + "name": "standards.FIDO2PasskeyProfiles.AAGUIDs", + "label": "AAGUIDs (comma-separated list of authenticator AAGUIDs)" + } + ], + "label": "Configure FIDO2 Passkey Profile", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-25", + "powershellEquivalent": "Graph API PATCH /policies/authenticationMethodsPolicy/authenticationMethodConfigurations/fido2", + "recommendedBy": ["CIPP"] + }, + { + "name": "standards.SmartLockout", + "cat": "Entra (AAD) Standards", + "tag": ["CIS M365 7.0.0 (5.2.3.8)", "CIS M365 7.0.0 (5.2.3.9)"], + "appliesToTest": ["CIS_5_2_3_8", "CIS_5_2_3_9"], + "helpText": "**Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode.", + "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced).", + "addedComponent": [ + { + "type": "number", + "name": "standards.SmartLockout.LockoutDurationInSeconds", + "label": "Lockout Duration (seconds)", + "default": 60, + "required": true + }, + { + "type": "number", + "name": "standards.SmartLockout.LockoutThreshold", + "label": "Lockout Threshold (failed attempts)", + "default": 10, + "required": true + }, + { + "type": "switch", + "name": "standards.SmartLockout.EnableBannedPasswordCheckOnPremises", + "label": "Enable On-Premises Password Protection" + }, + { + "type": "radio", + "name": "standards.SmartLockout.BannedPasswordCheckOnPremisesMode", + "label": "On-Premises Mode", + "options": [ + { "label": "Audit", "value": "Audit" }, + { "label": "Enforced", "value": "Enforced" } + ] + } + ], + "label": "Configure Entra ID Smart Lockout", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2025-05-27", + "powershellEquivalent": "Get-MgBetaDirectorySetting, New-MgBetaDirectorySetting, Update-MgBetaDirectorySetting", + "recommendedBy": ["CIS"], + "requiredCapabilities": ["AAD_PREMIUM", "AAD_PREMIUM_P2"] + }, + { + "name": "standards.SPOVersionControl", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Configures SharePoint Online file versioning to either use automatic version trimming managed by Microsoft, or enforce a fixed major version limit with optional version expiration.", + "docsDescription": "Configures the SharePoint Online tenant-level file versioning policy. When automatic version trimming is enabled, Microsoft intelligently manages version cleanup. When disabled, you can set a fixed maximum number of major versions to retain and optionally expire versions after a specified number of days. This helps manage storage consumption while preserving version history as needed.", + "executiveText": "Controls how SharePoint Online manages file version history at the tenant level. Automatic trimming lets Microsoft optimize storage by cleaning up old versions intelligently. Manual limits give administrators precise control over the maximum number of versions retained and their expiration, ensuring predictable storage usage and compliance with data retention policies.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.SPOVersionControl.EnableAutoTrim", + "label": "Enable Automatic Version Trimming (Microsoft managed)" + }, + { + "type": "number", + "name": "standards.SPOVersionControl.MajorVersionLimit", + "label": "Maximum Major Versions (when auto trim is off)", + "default": 50 + }, + { + "type": "number", + "name": "standards.SPOVersionControl.ExpireVersionsAfterDays", + "label": "Expire Versions After Days (0 = never, when auto trim is off)", + "default": 0 + }, + { + "type": "switch", + "name": "standards.SPOVersionControl.ApplyToExistingSites", + "label": "Apply to all existing sites and document libraries" + } + ], + "label": "Set SharePoint File Version Limits", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-05-27", + "powershellEquivalent": "Set-SPOTenant -EnableAutoExpirationVersionTrim $true or Set-SPOTenant -EnableAutoExpirationVersionTrim $false -MajorVersionLimit 50 -ExpireVersionsAfterDays 365", + "recommendedBy": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + } } ] diff --git a/src/data/tutorials/dashboard-overview.json b/src/data/tutorials/dashboard-overview.json new file mode 100644 index 000000000000..df2aeca19615 --- /dev/null +++ b/src/data/tutorials/dashboard-overview.json @@ -0,0 +1,111 @@ +{ + "id": "dashboard-overview", + "title": "Dashboard Overview", + "description": "A guided tour of the CIPP Dashboard — learn what each card, chart, and control does.", + "category": "General", + "pages": ["/dashboardv2"], + "steps": [ + { + "popover": { + "title": "Welcome to the Dashboard 👋", + "description": "This tour will walk you through every section of the CIPP Dashboard so you know exactly where to find the information you need." + } + }, + { + "element": "[data-tutorial='tenant-selector']", + "popover": { + "title": "Select a Tenant", + "description": "Start here. Pick the customer tenant you want to inspect. The entire dashboard updates to show data for the selected tenant.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-portals']", + "popover": { + "title": "Quick Portal Access", + "description": "Jump straight into the Microsoft admin portals (M365, Exchange, Entra, Intune, Azure, etc.) for the selected tenant — no need to look up URLs.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-test-suite']", + "popover": { + "title": "Test Suite Selector", + "description": "Choose which test suite to run against the tenant. You can create custom test suites, refresh results, or edit existing ones from here.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-tenant-info']", + "popover": { + "title": "Tenant Information", + "description": "Quick-reference card showing the tenant's display name, tenant ID, and primary domain. Click the chips to copy values to your clipboard.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-tenant-metrics']", + "popover": { + "title": "Tenant Metrics", + "description": "At-a-glance counts for Users, Guests, Groups, Service Principals, Devices, and Managed Devices. Click any metric to drill into the full list.", + "side": "bottom", + "align": "center" + } + }, + { + "element": "[data-tutorial='dashboard-assessment']", + "popover": { + "title": "Assessment Results", + "description": "A summary of the selected test suite results broken down by category (Identity, Devices, Custom). Green = passed, Red = failed, Orange = skipped.", + "side": "bottom", + "align": "end" + } + }, + { + "element": "[data-tutorial='dashboard-secure-score']", + "popover": { + "title": "Secure Score Trend", + "description": "A line chart tracking the tenant's Microsoft Secure Score over time. The reference line shows the maximum possible score, so you can gauge progress.", + "side": "right", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-mfa']", + "popover": { + "title": "User Authentication (MFA)", + "description": "This Sankey diagram shows how many enabled users are MFA-registered vs. not, and what enforcement method protects them (Conditional Access, Security Defaults, Per-user MFA, or none). Click any node to jump to the MFA report.", + "side": "left", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-auth-methods']", + "popover": { + "title": "Auth Methods Breakdown", + "description": "See how users authenticate — single-factor vs. multi-factor, phishable vs. phish-resistant — and which methods (Phone, Authenticator, Passkey, WHfB) they use. Click a node to filter the MFA report.", + "side": "right", + "align": "start" + } + }, + { + "element": "[data-tutorial='dashboard-licenses']", + "popover": { + "title": "License Overview", + "description": "Shows the top licenses in the tenant with assigned vs. available counts. Use this to spot unused or over-provisioned licenses at a glance.", + "side": "left", + "align": "start" + } + }, + { + "popover": { + "title": "You're all set! 🎉", + "description": "That covers the Dashboard. Explore the other tabs at the top for more views, or check the sidebar for all CIPP modules. You can replay this tour anytime from the Tutorials menu." + } + } + ] +} diff --git a/src/data/tutorials/getting-started.json b/src/data/tutorials/getting-started.json new file mode 100644 index 000000000000..8e2c2291350a --- /dev/null +++ b/src/data/tutorials/getting-started.json @@ -0,0 +1,48 @@ +{ + "id": "getting-started", + "title": "Getting Started with CIPP", + "description": "Learn the basics of navigating CIPP and managing your tenants.", + "category": "General", + "pages": ["/"], + "steps": [ + { + "popover": { + "title": "Welcome to CIPP! 👋", + "description": "This quick tour will show you the key features of the CIPP dashboard. Let's get started!" + } + }, + { + "element": "[data-tutorial='tenant-selector']", + "popover": { + "title": "Tenant Selector", + "description": "Use the tenant selector to switch between your managed tenants. You can search by name or select 'All Tenants' for a global view.", + "side": "bottom", + "align": "start" + } + }, + { + "element": "[data-tutorial='side-nav']", + "popover": { + "title": "Navigation Menu", + "description": "The sidebar gives you access to all CIPP modules — Identity, Endpoint, Security, Email, Teams & SharePoint, and more.", + "side": "right", + "align": "start" + } + }, + { + "element": "[data-tutorial='speed-dial']", + "popover": { + "title": "Quick Actions", + "description": "Use the help button in the bottom-right corner to report bugs, request features, join Discord, or access the documentation.", + "side": "left", + "align": "end" + } + }, + { + "popover": { + "title": "You're all set! 🎉", + "description": "You now know the basics. Explore the sidebar to discover all the tools CIPP offers. You can replay this tour anytime from the Tutorials menu." + } + } + ] +} diff --git a/src/data/tutorials/tenant-management.json b/src/data/tutorials/tenant-management.json new file mode 100644 index 000000000000..0492fd8fdac5 --- /dev/null +++ b/src/data/tutorials/tenant-management.json @@ -0,0 +1,39 @@ +{ + "id": "tenant-management", + "title": "Managing Tenants", + "description": "Learn how to view tenant details, manage tenant settings, and navigate tenant-specific pages.", + "category": "Administration", + "pages": ["/tenant/administration/tenants"], + "steps": [ + { + "popover": { + "title": "Tenant Management", + "description": "This page shows all your managed tenants. Let's walk through the key features." + } + }, + { + "element": "[data-tutorial='breadcrumb-nav']", + "popover": { + "title": "Breadcrumb Navigation", + "description": "Use the breadcrumb trail to see where you are and quickly navigate back to parent pages.", + "side": "bottom", + "align": "start" + } + }, + { + "element": ".MuiTableContainer-root", + "popover": { + "title": "Tenant List", + "description": "Here you'll find all your managed tenants. Click on any row to see detailed information about that tenant.", + "side": "top", + "align": "center" + } + }, + { + "popover": { + "title": "That's it!", + "description": "You now know how to manage your tenants. Check out other tutorials for more advanced features." + } + } + ] +} diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index 1b5585a6812a..d217c9c87d5e 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -17,6 +17,7 @@ import { } from "@mui/material"; import { ActionsMenu } from "../components/actions-menu"; import { useMediaQuery } from "@mui/material"; +import { getIconByName } from "../utils/icon-registry"; export const HeaderedTabbedLayout = (props) => { const { @@ -102,10 +103,32 @@ export const HeaderedTabbedLayout = (props) => { )}
    - - {tabOptions.map((option) => ( - - ))} + + {tabOptions.map((option) => { + const icon = getIconByName(option.icon, { fontSize: "small" }); + const iconPosition = option.iconPosition ?? "start"; + const compactIcon = icon && ["end", "start"].includes(iconPosition); + + return ( + + ); + })}
    @@ -133,6 +156,8 @@ HeaderedTabbedLayout.propTypes = { PropTypes.shape({ label: PropTypes.string.isRequired, path: PropTypes.string.isRequired, + icon: PropTypes.string, + iconPosition: PropTypes.oneOf(["bottom", "end", "start", "top"]), }) ).isRequired, title: PropTypes.string.isRequired, diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index a085b528b45c..33c861fb85dd 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -1,6 +1,9 @@ +import { useMemo } from 'react' import { usePathname, useRouter } from 'next/navigation' import { Box, Divider, Stack, Tab, Tabs } from '@mui/material' import { useSearchParams } from 'next/navigation' +import { ApiGetCall } from '../api/ApiCall' +import { getIconByName } from '../utils/icon-registry' export const TabbedLayout = (props) => { const { tabOptions, children } = props @@ -8,6 +11,25 @@ export const TabbedLayout = (props) => { const pathname = usePathname() const searchParams = useSearchParams() + const featureFlags = ApiGetCall({ + url: '/api/ListFeatureFlags', + queryKey: 'featureFlags', + staleTime: 600000, + }) + + const visibleTabs = useMemo(() => { + if (!featureFlags.isSuccess || !Array.isArray(featureFlags.data)) return tabOptions + + const disabledPages = featureFlags.data + .filter((flag) => flag.Enabled === false || flag.enabled === false) + .flatMap((flag) => flag.Pages || flag.pages || []) + .filter((page) => typeof page === 'string') + + if (disabledPages.length === 0) return tabOptions + + return tabOptions.filter((option) => !disabledPages.includes(option.path)) + }, [tabOptions, featureFlags.isSuccess, featureFlags.data]) + const handleTabsChange = (event, value) => { // Preserve existing query parameters when changing tabs const currentParams = new URLSearchParams(searchParams.toString()) @@ -16,7 +38,7 @@ export const TabbedLayout = (props) => { router.push(newPath) } - const currentTab = tabOptions.find((option) => option.path === pathname) + const currentTab = visibleTabs.find((option) => option.path === pathname) return ( { variant="scrollable" sx={{ '& .MuiTab-root:first-of-type': { - ml: 1, + ml: 2, }, }} > - {tabOptions.map((option) => ( - - ))} + {visibleTabs.map((option) => { + const icon = getIconByName(option.icon, { fontSize: 'small' }) + const iconPosition = option.iconPosition ?? 'start' + const compactIcon = icon && ['end', 'start'].includes(iconPosition) + + return ( + + ) + })} diff --git a/src/layouts/config.js b/src/layouts/config.js index c820f5664acc..7a41ee8ca57f 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -301,11 +301,10 @@ export const nativeMenuItems = [ 'Security.Alert.*', 'Tenant.DeviceCompliance.*', 'Security.SafeLinksPolicy.*', - // TEMP: Purview Compliance menu hidden for dev build - // 'Security.DlpCompliancePolicy.*', - // 'Security.RetentionCompliancePolicy.*', - // 'Security.SensitivityLabel.*', - // 'Security.SensitiveInfoType.*', + 'Security.DlpCompliancePolicy.*', + 'Security.RetentionCompliancePolicy.*', + 'Security.SensitivityLabel.*', + 'Security.SensitiveInfoType.*', ], items: [ { @@ -388,62 +387,61 @@ export const nativeMenuItems = [ }, ], }, - // TEMP: Purview Compliance menu hidden for dev build - // { - // title: 'Purview Compliance', - // permissions: [ - // 'Security.DlpCompliancePolicy.*', - // 'Security.RetentionCompliancePolicy.*', - // 'Security.SensitivityLabel.*', - // 'Security.SensitiveInfoType.*', - // ], - // items: [ - // { - // title: 'DLP Policies', - // path: '/security/compliance/dlp', - // permissions: ['Security.DlpCompliancePolicy.*'], - // }, - // { - // title: 'DLP Policy Templates', - // path: '/security/compliance/dlp-templates', - // permissions: ['Security.DlpCompliancePolicy.*'], - // scope: 'global', - // }, - // { - // title: 'Retention Policies', - // path: '/security/compliance/retention', - // permissions: ['Security.RetentionCompliancePolicy.*'], - // }, - // { - // title: 'Retention Policy Templates', - // path: '/security/compliance/retention-templates', - // permissions: ['Security.RetentionCompliancePolicy.*'], - // scope: 'global', - // }, - // { - // title: 'Sensitivity Labels', - // path: '/security/compliance/labels', - // permissions: ['Security.SensitivityLabel.*'], - // }, - // { - // title: 'Sensitivity Label Templates', - // path: '/security/compliance/labels-templates', - // permissions: ['Security.SensitivityLabel.*'], - // scope: 'global', - // }, - // { - // title: 'Sensitive Information Types', - // path: '/security/compliance/sit', - // permissions: ['Security.SensitiveInfoType.*'], - // }, - // { - // title: 'Sensitive Info Type Templates', - // path: '/security/compliance/sit-templates', - // permissions: ['Security.SensitiveInfoType.*'], - // scope: 'global', - // }, - // ], - // }, + { + title: 'Purview Compliance', + permissions: [ + 'Security.DlpCompliancePolicy.*', + 'Security.RetentionCompliancePolicy.*', + 'Security.SensitivityLabel.*', + 'Security.SensitiveInfoType.*', + ], + items: [ + { + title: 'DLP Policies', + path: '/security/compliance/dlp', + permissions: ['Security.DlpCompliancePolicy.*'], + }, + { + title: 'DLP Policy Templates', + path: '/security/compliance/dlp-templates', + permissions: ['Security.DlpCompliancePolicy.*'], + scope: 'global', + }, + { + title: 'Retention Policies', + path: '/security/compliance/retention', + permissions: ['Security.RetentionCompliancePolicy.*'], + }, + { + title: 'Retention Policy Templates', + path: '/security/compliance/retention-templates', + permissions: ['Security.RetentionCompliancePolicy.*'], + scope: 'global', + }, + { + title: 'Sensitivity Labels', + path: '/security/compliance/labels', + permissions: ['Security.SensitivityLabel.*'], + }, + { + title: 'Sensitivity Label Templates', + path: '/security/compliance/labels-templates', + permissions: ['Security.SensitivityLabel.*'], + scope: 'global', + }, + { + title: 'Sensitive Information Types', + path: '/security/compliance/sit', + permissions: ['Security.SensitiveInfoType.*'], + }, + { + title: 'Sensitive Info Type Templates', + path: '/security/compliance/sit-templates', + permissions: ['Security.SensitiveInfoType.*'], + scope: 'global', + }, + ], + }, ], }, { @@ -484,7 +482,7 @@ export const nativeMenuItems = [ ], }, { - title: 'Autopilot', + title: 'Autopilot & Enrollment', permissions: ['Endpoint.Autopilot.*'], items: [ { @@ -498,8 +496,8 @@ export const nativeMenuItems = [ permissions: ['Endpoint.Autopilot.*'], }, { - title: 'Profiles', - path: '/endpoint/autopilot/list-profiles', + title: 'Enrollment Profiles', + path: '/endpoint/autopilot/enrollment-profiles', permissions: ['Endpoint.Autopilot.*'], }, { @@ -676,6 +674,11 @@ export const nativeMenuItems = [ path: '/email/administration/mailboxes', permissions: ['Exchange.Mailbox.*'], }, + { + title: 'HVE Accounts', + path: '/email/administration/hve-accounts', + permissions: ['Exchange.Mailbox.*'], + }, { title: 'Deleted Mailboxes', path: '/email/administration/deleted-mailboxes', @@ -1106,6 +1109,20 @@ export const nativeMenuItems = [ permissions: ['CIPP.SuperAdmin.*'], scope: 'global', }, + { + title: 'Container Logs', + path: '/cipp/advanced/container-logs', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + { + title: 'Worker Health', + path: '/cipp/advanced/worker-health', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, ], }, ], diff --git a/src/layouts/index.js b/src/layouts/index.js index b741d5bd0ea4..f3c178556ff3 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -27,13 +27,17 @@ import { CippImageCard } from '../components/CippCards/CippImageCard' import { useDialog } from '../hooks/use-dialog' import { nativeMenuItems } from './config' import { CippBreadcrumbNav } from '../components/CippComponents/CippBreadcrumbNav' +import { SsoMigrationDialog } from '../components/CippComponents/SsoMigrationDialog' +import { ForcedSsoMigrationDialog } from '../components/CippComponents/ForcedSsoMigrationDialog' +import { SubscriptionEndedDialog } from '../components/CippComponents/SubscriptionEndedDialog' +import { FailedPaymentDialog } from '../components/CippComponents/FailedPaymentDialog' const OnboardingWizardPage = dynamic( () => import('../components/CippWizard/OnboardingWizardPage.jsx'), { ssr: false } ) -const SIDE_NAV_WIDTH = 270 +const SIDE_NAV_WIDTH = 290 const SIDE_NAV_PINNED_WIDTH = 50 const TOP_NAV_HEIGHT = 50 @@ -109,7 +113,7 @@ export const Layout = (props) => { const currentRole = ApiGetCall({ url: '/api/me', queryKey: 'authmecipp', - waiting: !swaStatus.isSuccess || swaStatus.data?.clientPrincipal === null, + waiting: swaStatus.isSuccess && swaStatus.data?.clientPrincipal !== null, }) const featureFlags = ApiGetCall({ @@ -335,6 +339,10 @@ export const Layout = (props) => {
    + + + + {!setupCompleted && ( diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index ec43e9fb857f..1dadfca01577 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -1,64 +1,64 @@ -import { useState, useRef, useEffect } from "react"; -import { usePathname } from "next/navigation"; -import PropTypes from "prop-types"; -import { Box, Divider, Drawer, Stack } from "@mui/material"; -import { SideNavItem } from "./side-nav-item"; -import { SideNavBookmarks } from "./side-nav-bookmarks"; -import { ApiGetCall } from "../api/ApiCall.jsx"; -import { CippSponsor } from "../components/CippComponents/CippSponsor"; -import { useSettings } from "../hooks/use-settings"; - -const SIDE_NAV_WIDTH = 270; -const SIDE_NAV_COLLAPSED_WIDTH = 73; // icon size + padding + border right -const TOP_NAV_HEIGHT = 64; +import { useState, useRef, useEffect } from 'react' +import { usePathname } from 'next/navigation' +import PropTypes from 'prop-types' +import { Box, Divider, Drawer, Stack } from '@mui/material' +import { SideNavItem } from './side-nav-item' +import { SideNavBookmarks } from './side-nav-bookmarks' +import { ApiGetCall } from '../api/ApiCall.jsx' +import { CippSponsor } from '../components/CippComponents/CippSponsor' +import { useSettings } from '../hooks/use-settings' + +const SIDE_NAV_WIDTH = 290 +const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right +const TOP_NAV_HEIGHT = 64 const isPathPrefix = (pathname, itemPath) => { - if (!pathname || !itemPath) return false; - if (pathname === itemPath) return true; + if (!pathname || !itemPath) return false + if (pathname === itemPath) return true // Root "/" maps to /dashboardv2 under the hood - if (itemPath === "/") return pathname.startsWith("/dashboardv2"); - return pathname.startsWith(itemPath + "/") || pathname.startsWith(itemPath + "?"); -}; + if (itemPath === '/') return pathname.startsWith('/dashboardv2') + return pathname.startsWith(itemPath + '/') || pathname.startsWith(itemPath + '?') +} const markOpenItems = (items, pathname) => { return items.map((item) => { - const checkPath = !!(item.path && pathname); - const exactMatch = checkPath ? pathname === item.path : false; - const partialMatch = checkPath ? isPathPrefix(pathname, item.path) : false; + const checkPath = !!(item.path && pathname) + const exactMatch = checkPath ? pathname === item.path : false + const partialMatch = checkPath ? isPathPrefix(pathname, item.path) : false - let openImmediately = exactMatch; - let newItems = item.items || []; + let openImmediately = exactMatch + let newItems = item.items || [] if (newItems.length > 0) { - newItems = markOpenItems(newItems, pathname); - const childOpen = newItems.some((child) => child.openImmediately); - openImmediately = openImmediately || childOpen || exactMatch; // Ensure parent opens if child is open + newItems = markOpenItems(newItems, pathname) + const childOpen = newItems.some((child) => child.openImmediately) + openImmediately = openImmediately || childOpen || exactMatch // Ensure parent opens if child is open } else { - openImmediately = openImmediately || partialMatch; // Leaf items open on partial match + openImmediately = openImmediately || partialMatch // Leaf items open on partial match } return { ...item, items: newItems, openImmediately, - }; - }); -}; + } + }) +} -const renderItems = ({ collapse = false, depth = 0, items, pathname, category = "" }) => +const renderItems = ({ collapse = false, depth = 0, items, pathname, category = '' }) => items.reduce( (acc, item) => reduceChildRoutes({ acc, collapse, depth, item, pathname, category }), [] - ); + ) const reduceChildRoutes = ({ acc, collapse, depth, item, pathname, category }) => { - const checkPath = !!(item.path && pathname); - const exactMatch = checkPath && pathname === item.path; - const partialMatch = checkPath ? isPathPrefix(pathname, item.path) : false; + const checkPath = !!(item.path && pathname) + const exactMatch = checkPath && pathname === item.path + const partialMatch = checkPath ? isPathPrefix(pathname, item.path) : false - const hasChildren = item.items && item.items.length > 0; - const isActive = exactMatch || (partialMatch && !hasChildren); - const currentCategory = depth === 0 && item.type === "header" ? item.title : category; + const hasChildren = item.items && item.items.length > 0 + const isActive = exactMatch || (partialMatch && !hasChildren) + const currentCategory = depth === 0 && item.type === 'header' ? item.title : category if (hasChildren) { acc.push( @@ -80,7 +80,7 @@ const reduceChildRoutes = ({ acc, collapse, depth, item, pathname, category }) = component="ul" spacing={0.5} sx={{ - listStyle: "none", + listStyle: 'none', m: 0, p: 0, }} @@ -94,7 +94,7 @@ const reduceChildRoutes = ({ acc, collapse, depth, item, pathname, category }) = })} - ); + ) } else { acc.push( - ); + ) } - return acc; -}; + return acc +} export const SideNav = (props) => { - const { items, onPin, pinned = false } = props; - const pathname = usePathname(); - const [hovered, setHovered] = useState(false); - const collapse = !(pinned || hovered); - const { data: profile } = ApiGetCall({ url: "/api/me", queryKey: "authmecipp" }); - const settings = useSettings(); - const showSidebarBookmarks = settings.bookmarkSidebar !== false; - const paperRef = useRef(null); + const { items, onPin, pinned = false } = props + const pathname = usePathname() + const [hovered, setHovered] = useState(false) + const collapse = !(pinned || hovered) + const { data: profile } = ApiGetCall({ url: '/api/me', queryKey: 'authmecipp' }) + const settings = useSettings() + const showSidebarBookmarks = settings.bookmarkSidebar !== false + const paperRef = useRef(null) // Intercept wheel events on the side nav to fully isolate scroll. // preventDefault stops wheel events from reaching the main content, // and manual scrollTop has no momentum so it stops instantly when the cursor leaves. // Uses RAF-based easing to smooth out discrete mouse wheel jumps. useEffect(() => { - const el = paperRef.current; - if (!el) return; + const el = paperRef.current + if (!el) return - let targetScrollTop = el.scrollTop; - let animating = false; + let targetScrollTop = el.scrollTop + let animating = false const animate = () => { - const diff = targetScrollTop - el.scrollTop; + const diff = targetScrollTop - el.scrollTop if (Math.abs(diff) < 0.5) { - el.scrollTop = targetScrollTop; - animating = false; - return; + el.scrollTop = targetScrollTop + animating = false + return } - el.scrollTop += diff * 0.25; - requestAnimationFrame(animate); - }; + el.scrollTop += diff * 0.25 + requestAnimationFrame(animate) + } const handleWheel = (e) => { - e.preventDefault(); - const maxScroll = el.scrollHeight - el.clientHeight; - targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop + e.deltaY)); + e.preventDefault() + const maxScroll = el.scrollHeight - el.clientHeight + targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop + e.deltaY)) if (!animating) { - animating = true; - requestAnimationFrame(animate); + animating = true + requestAnimationFrame(animate) } - }; + } - el.addEventListener("wheel", handleWheel, { passive: false }); - return () => el.removeEventListener("wheel", handleWheel); - }, []); + el.addEventListener('wheel', handleWheel, { passive: false }) + return () => el.removeEventListener('wheel', handleWheel) + }, []) // Preprocess items to mark which should be open - const processedItems = markOpenItems(items, pathname); + const processedItems = markOpenItems(items, pathname) return ( <> {profile?.clientPrincipal && profile?.clientPrincipal?.userRoles?.length > 2 && ( setHovered(true), onMouseLeave: () => setHovered(false), sx: { - backgroundColor: "background.default", + backgroundColor: 'background.default', height: `calc(100% - ${TOP_NAV_HEIGHT}px)`, - overflowX: "hidden", - overflowY: "auto", - scrollbarGutter: "stable", + overflowX: 'hidden', + overflowY: 'auto', + scrollbarGutter: 'stable', top: TOP_NAV_HEIGHT, - transition: "width 250ms ease-in-out", + transition: 'width 250ms ease-in-out', width: collapse ? SIDE_NAV_COLLAPSED_WIDTH : SIDE_NAV_WIDTH, zIndex: (theme) => theme.zIndex.appBar - 100, }, @@ -189,9 +190,9 @@ export const SideNav = (props) => { @@ -199,7 +200,7 @@ export const SideNav = (props) => { component="ul" sx={{ flexGrow: 1, - listStyle: "none", + listStyle: 'none', m: 0, p: 0, }} @@ -218,24 +219,24 @@ export const SideNav = (props) => { items: processedItems, pathname, })} - {" "} + {' '} {/* Add this closing tag */} {profile?.clientPrincipal && ( )} - {" "} + {' '} {/* Closing tag for the parent Box */} )} - ); -}; + ) +} SideNav.propTypes = { onPin: PropTypes.func, pinned: PropTypes.bool, -}; +} diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 41d5f07e0f2f..10bdcefd9581 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -41,6 +41,8 @@ import { CippTenantSelector } from '../components/CippComponents/CippTenantSelec import { NotificationsPopover } from './notifications-popover' import { useDialog } from '../hooks/use-dialog' import { CippUniversalSearchV2 } from '../components/CippCards/CippUniversalSearchV2' +import { CippOffCanvas } from '../components/CippComponents/CippOffCanvas' +import { CippLicenseDetailsDrawer } from '../components/CippComponents/CippLicenseDetailsDrawer' const TOP_NAV_HEIGHT = 64 @@ -69,6 +71,8 @@ export const TopNav = (props) => { const [flashSort, setFlashSort] = useState(false) const [flashLock, setFlashLock] = useState(false) const [universalSearchKey, setUniversalSearchKey] = useState(0) + const [licenseDrawerVisible, setLicenseDrawerVisible] = useState(false) + const [licenseDrawerData, setLicenseDrawerData] = useState(null) const [universalSearchDefaultType, setUniversalSearchDefaultType] = useState('Users') const itemRefs = useRef({}) const touchDragRef = useRef({ startIdx: null, overIdx: null }) @@ -278,7 +282,13 @@ export const TopNav = (props) => { {!mdDown && ( - + + + )} {mdDown && ( @@ -627,10 +637,23 @@ export const TopNav = (props) => { autoFocus={true} defaultSearchType={universalSearchDefaultType} onConfirm={closeUniversalSearch} + onLicenseSelect={(licenseData) => { + setLicenseDrawerData(licenseData) + setLicenseDrawerVisible(true) + }} /> + setLicenseDrawerVisible(false)} + size="xl" + contentPadding={0} + > + + import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({ @@ -76,6 +81,7 @@ const App = (props) => { const pathname = usePathname() const route = useRouter() const [dateLocale, setDateLocale] = useState(enUS) + const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false) useEffect(() => { if (typeof window === 'undefined') return @@ -243,6 +249,12 @@ const App = (props) => { href: `https://docs.cipp.app/user-documentation${pathname}`, onClick: () => window.open(`https://docs.cipp.app/user-documentation${pathname}`, '_blank'), }, + { + id: 'tutorials', + icon: , + name: 'Tutorials', + onClick: () => setTutorialDialogOpen(true), + }, ] return ( @@ -275,9 +287,15 @@ const App = (props) => { - - {getLayout()} - + + + {getLayout()} + + setTutorialDialogOpen(false)} + /> + diff --git a/src/pages/authredirect.js b/src/pages/authredirect.js index ea8edfe0937d..7295c7f1f9c9 100644 --- a/src/pages/authredirect.js +++ b/src/pages/authredirect.js @@ -1,47 +1,36 @@ -import { Box, Container, Stack } from "@mui/material"; -import { Grid } from "@mui/system"; -import Head from "next/head"; -import { CippImageCard } from "../components/CippCards/CippImageCard.jsx"; -import { Layout as DashboardLayout } from "../layouts/index.js"; +import { useEffect } from 'react' +import Head from 'next/head' -const Page = () => ( - <> - +const Page = () => { + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + const state = params.get('state') + const error = params.get('error') + const errorDescription = params.get('error_description') + + const channel = new BroadcastChannel('cipp_auth') + if (code && state) { + channel.postMessage({ type: 'auth_code', code, state }) + } else if (error) { + channel.postMessage({ type: 'auth_error', error, errorDescription }) + } + channel.close() + window.close() + }, []) + + return ( + <> Authentication complete - - - - - - - - - - - - - -); +

    Authentication complete. This window will close automatically.

    +
    + + ) +} -export default Page; +export default Page diff --git a/src/pages/cipp/advanced/container-logs.js b/src/pages/cipp/advanced/container-logs.js new file mode 100644 index 000000000000..b77aac6590a9 --- /dev/null +++ b/src/pages/cipp/advanced/container-logs.js @@ -0,0 +1,582 @@ +import { useState, useEffect, useMemo } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { + Box, + Button, + Stack, + Typography, + Chip, + Accordion, + AccordionSummary, + AccordionDetails, + Alert, + AlertTitle, + Tab, + Tabs, +} from "@mui/material"; +import { ExpandMore, Search, Refresh, PlayArrow } from "@mui/icons-material"; +import { CippFormComponent } from "../../../components/CippComponents/CippFormComponent"; +import { Grid } from "@mui/system"; +import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { CippTablePage } from "../../../components/CippComponents/CippTablePage"; +import { ApiGetCall } from "../../../api/ApiCall"; +import defaultPresets from "../../../data/ContainerLogPresets.json"; + +const levelOptions = [ + { label: "All Levels", value: "" }, + { label: "Debug", value: "DBG" }, + { label: "Information", value: "INF" }, + { label: "Warning", value: "WRN" }, + { label: "Error", value: "ERR" }, + { label: "Critical", value: "CRT" }, +]; + +const timeRangeOptions = [ + { label: "Last 15 minutes", value: "15" }, + { label: "Last 30 minutes", value: "30" }, + { label: "Last 1 hour", value: "60" }, + { label: "Last 3 hours", value: "180" }, + { label: "Last 6 hours", value: "360" }, + { label: "Last 12 hours", value: "720" }, + { label: "Last 24 hours", value: "1440" }, + { label: "Custom Range", value: "custom" }, + { label: "No Time Filter", value: "" }, +]; + +const getLevelColor = (level) => { + switch (level) { + case "CRT": + return "error"; + case "ERR": + return "error"; + case "WRN": + return "warning"; + case "INF": + return "info"; + case "DBG": + return "default"; + default: + return "default"; + } +}; + +const getLevelLabel = (level) => { + switch (level) { + case "CRT": + return "Critical"; + case "ERR": + return "Error"; + case "WRN": + return "Warning"; + case "INF": + return "Info"; + case "DBG": + return "Debug"; + case "TRC": + return "Trace"; + default: + return level || "Unknown"; + } +}; + +const ContainerLogsFilter = ({ onSubmitFilter }) => { + const [expanded, setExpanded] = useState(true); + const [tabValue, setTabValue] = useState(0); // 0 = Query, 1 = Guided + const [selectedPreset, setSelectedPreset] = useState(null); + + // Query mode form + const queryForm = useForm({ + mode: "onChange", + defaultValues: { + queryPreset: null, + query: 'where Timestamp > ago(1h)\n| take 500\n| sort by Timestamp desc', + }, + }); + + const queryValue = useWatch({ control: queryForm.control, name: "query" }); + const queryPreset = useWatch({ control: queryForm.control, name: "queryPreset" }); + + // Guided mode form + const guidedForm = useForm({ + mode: "onChange", + defaultValues: { + timeRange: "60", + level: "", + search: "", + exclude: "", + regex: "", + file: "", + tail: "500", + searchAll: false, + sortDesc: true, + fromDate: "", + toDate: "", + }, + }); + + const timeRange = useWatch({ control: guidedForm.control, name: "timeRange" }); + + // Preset options (built-in only — no API save/load for container log presets) + const presetOptions = useMemo( + () => + defaultPresets.map((preset) => ({ + label: preset.name, + value: preset.id, + query: preset.query, + isBuiltin: true, + })), + [] + ); + + // Load preset when selected + useEffect(() => { + if (queryPreset) { + const preset = Array.isArray(queryPreset) ? queryPreset[0] : queryPreset; + if (preset?.query) { + queryForm.setValue("query", preset.query); + setSelectedPreset(preset); + queryForm.setValue("queryPreset", null); + } + } + }, [queryPreset, queryForm]); + + const fileListQuery = ApiGetCall({ + url: "/api/ListContainerLogs", + data: { Action: "ListFiles" }, + queryKey: "ContainerLogFiles", + }); + + const fileOptions = useMemo(() => { + const opts = [{ label: "Current Log", value: "" }]; + if (fileListQuery.isSuccess && fileListQuery.data?.Results) { + fileListQuery.data.Results.forEach((f) => { + if (!f.IsCurrent) { + opts.push({ + label: `${f.Name} (${f.SizeFormatted})`, + value: f.Name, + }); + } + }); + } + return opts; + }, [fileListQuery.isSuccess, fileListQuery.data]); + + // Submit query mode + const handleQuerySubmit = queryForm.handleSubmit((values) => { + if (values.query && values.query.trim()) { + onSubmitFilter({ + Action: "Query", + Query: values.query.trim(), + }); + setExpanded(false); + } + }); + + // Submit guided mode + const handleGuidedSubmit = guidedForm.handleSubmit((values) => { + const params = { + Action: values.searchAll ? "SearchAll" : "ReadLog", + Tail: values.tail || "500", + }; + + if (values.sortDesc) params.SortDesc = "true"; + + // Level filter + const levelVal = Array.isArray(values.level) ? values.level[0]?.value : values.level; + if (levelVal) params.Level = levelVal; + + // Search text + if (values.search) params.Search = values.search; + + // Exclude text + if (values.exclude) params.Exclude = values.exclude; + + // Regex pattern + if (values.regex) params.Regex = values.regex; + + // File selection + const fileVal = Array.isArray(values.file) ? values.file[0]?.value : values.file; + if (fileVal && !values.searchAll) params.File = fileVal; + + // Time range + const rangeVal = Array.isArray(values.timeRange) + ? values.timeRange[0]?.value + : values.timeRange; + if (rangeVal === "custom") { + if (values.fromDate) params.From = new Date(values.fromDate).toISOString(); + if (values.toDate) params.To = new Date(values.toDate).toISOString(); + } else if (rangeVal && rangeVal !== "") { + const minutes = parseInt(rangeVal, 10); + if (!isNaN(minutes)) { + params.From = new Date(Date.now() - minutes * 60 * 1000).toISOString(); + } + } + + onSubmitFilter(params); + setExpanded(false); + }); + + const handleClear = () => { + if (tabValue === 0) { + queryForm.reset(); + setSelectedPreset(null); + } else { + guidedForm.reset(); + } + onSubmitFilter(null); + setExpanded(true); + }; + + return ( + setExpanded(!expanded)}> + }> + Log Query + + + + setTabValue(v)} + sx={{ borderBottom: 1, borderColor: "divider" }} + > + + + + + {/* ── Tab 0: Query Editor ── */} + {tabValue === 0 && ( + + + + Query Syntax + + Use a KQL-inspired pipe syntax to filter container logs. Separate clauses with{" "} + |. Supported operators: + + + where Level == "ERR" — exact level +
    + where Level in ("ERR", "CRT") — multiple + levels +
    + where Level != "DBG" — exclude level +
    + where Message contains "text" — search +
    + where Message !contains "text" — exclude +
    + where Message matches regex "err|fail" — regex +
    + where Timestamp > ago(1h) — relative time (s/m/h/d/w) +
    + where Timestamp between (ago(2h) .. ago(1h)) — range +
    + take 500 — limit results +
    + sort by Timestamp desc — newest first +
    + search all files — include rotated logs +
    +
    + + + + + + + + ago(1h)\n| take 500\n| sort by Timestamp desc`} + sx={{ + "& textarea": { + fontFamily: "monospace", + fontSize: "0.875rem", + }, + }} + /> + + + + + +
    +
    + )} + + {/* ── Tab 1: Guided Filter ── */} + {tabValue === 1 && ( + + + + Search the local container log files directly. Logs are rotated by size and + retained on disk. Use “Search All Files” to search across rotated log + files. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {(Array.isArray(timeRange) ? timeRange[0]?.value : timeRange) === "custom" && ( + + + + + + + + + )} + + + + + + + + + + + + + + + + + )} +
    +
    +
    + ); +}; + +const Page = () => { + const [apiFilter, setApiFilter] = useState(null); + const queryKey = JSON.stringify(apiFilter); + + return ( + + + + + + } + clearOnError={true} + offCanvas={{ + size: "lg", + children: (row) => { + const levelColor = getLevelColor(row.Level); + return ( + + + + + + + {row.Timestamp} + + + + + + Message + + + + {row.Message} + + + + {row.Raw && row.Raw !== row.Message && ( + + + Raw Log Line + + + + {row.Raw} + + + + )} + + + ); + }, + }} + title="Container Logs" + tenantInTitle={false} + apiDataKey="Results" + apiUrl={apiFilter ? "/api/ListContainerLogs" : "/api/ListEmptyResults"} + apiData={apiFilter} + queryKey={queryKey} + simpleColumns={["Timestamp", "Level", "Message"]} + actions={[]} + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/cipp/advanced/super-admin/cipp-users.js b/src/pages/cipp/advanced/super-admin/cipp-users.js new file mode 100644 index 000000000000..32f0534825b2 --- /dev/null +++ b/src/pages/cipp/advanced/super-admin/cipp-users.js @@ -0,0 +1,34 @@ +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import tabOptions from "./tabOptions"; +import CippPageCard from "../../../../components/CippCards/CippPageCard"; +import { CippUserManagement } from "../../../../components/CippSettings/CippUserManagement"; +import { CardContent, Stack, Alert } from "@mui/material"; + +const Page = () => { + return ( + + + + + Manage users who can access CIPP. 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. + + + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/cipp/advanced/super-admin/container.js b/src/pages/cipp/advanced/super-admin/container.js new file mode 100644 index 000000000000..9fb8a701174b --- /dev/null +++ b/src/pages/cipp/advanced/super-admin/container.js @@ -0,0 +1,21 @@ +import { Container } from "@mui/material"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import tabOptions from "./tabOptions"; +import { CippContainerManagement } from "../../../../components/CippSettings/CippContainerManagement"; + +const Page = () => { + return ( + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/cipp/advanced/super-admin/sso.js b/src/pages/cipp/advanced/super-admin/sso.js new file mode 100644 index 000000000000..fc5b112f3f1c --- /dev/null +++ b/src/pages/cipp/advanced/super-admin/sso.js @@ -0,0 +1,26 @@ +import { Container } from "@mui/material"; +import { Grid } from "@mui/system"; +import { TabbedLayout } from "../../../../layouts/TabbedLayout"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import tabOptions from "./tabOptions"; +import { CippSSOSettings } from "../../../../components/CippSettings/CippSSOSettings"; + +const Page = () => { + return ( + + + + + + + + ); +}; + +Page.getLayout = (page) => ( + + {page} + +); + +export default Page; diff --git a/src/pages/cipp/advanced/super-admin/tabOptions.json b/src/pages/cipp/advanced/super-admin/tabOptions.json index 672df76996c6..e0d9e9bad597 100644 --- a/src/pages/cipp/advanced/super-admin/tabOptions.json +++ b/src/pages/cipp/advanced/super-admin/tabOptions.json @@ -1,26 +1,47 @@ [ { "label": "Tenant Mode", - "path": "/cipp/advanced/super-admin/tenant-mode" + "path": "/cipp/advanced/super-admin/tenant-mode", + "icon": "Domain" }, { "label": "Function Offloading", - "path": "/cipp/advanced/super-admin/function-offloading" + "path": "/cipp/advanced/super-admin/function-offloading", + "icon": "Cloud" }, { "label": "Time Settings", - "path": "/cipp/advanced/super-admin/time-settings" + "path": "/cipp/advanced/super-admin/time-settings", + "icon": "AccessTime" }, { "label": "CIPP Roles", - "path": "/cipp/advanced/super-admin/cipp-roles" + "path": "/cipp/advanced/super-admin/cipp-roles", + "icon": "AdminPanelSettings" }, { "label": "SAM App Roles", - "path": "/cipp/advanced/super-admin/sam-app-roles" + "path": "/cipp/advanced/super-admin/sam-app-roles", + "icon": "Key" }, { "label": "SAM App Permissions", - "path": "/cipp/advanced/super-admin/sam-app-permissions" + "path": "/cipp/advanced/super-admin/sam-app-permissions", + "icon": "Lock" + }, + { + "label": "CIPP Users", + "path": "/cipp/advanced/super-admin/cipp-users", + "icon": "Group" + }, + { + "label": "SSO", + "path": "/cipp/advanced/super-admin/sso", + "icon": "Shield" + }, + { + "label": "Container Management", + "path": "/cipp/advanced/super-admin/container", + "icon": "Storage" } ] diff --git a/src/pages/cipp/advanced/worker-health.js b/src/pages/cipp/advanced/worker-health.js new file mode 100644 index 000000000000..29d8530cbe9e --- /dev/null +++ b/src/pages/cipp/advanced/worker-health.js @@ -0,0 +1,1188 @@ +import { Fragment, useCallback, useMemo, useRef, useState } from "react"; +import Head from "next/head"; +import { + Box, + Button, + Card, + CardContent, + CardHeader, + Chip, + CircularProgress, + Container, + IconButton, + LinearProgress, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from "@mui/material"; +import { + Memory, + Speed, + PlayArrow, + HourglassEmpty, + CheckCircle, + Warning, + Cancel, + Delete, + LowPriority, + Timeline, + RocketLaunch, + Pause, + FileDownload, + FileUpload, + Refresh, + Close, +} from "@mui/icons-material"; +import { Grid } from "@mui/system"; +import { useTheme } from "@mui/material/styles"; +import { useQueryClient } from "@tanstack/react-query"; +import { + AreaChart, + Area, + LineChart, + Line, + BarChart, + Bar, + CartesianGrid, + XAxis, + YAxis, + ResponsiveContainer, + Tooltip as RechartsTooltip, + Legend, +} from "recharts"; +import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { CippInfoBar } from "../../../components/CippCards/CippInfoBar"; +import { CippDataTable } from "../../../components/CippTable/CippDataTable"; +import { ApiGetCall, ApiPostCall } from "../../../api/ApiCall"; + +const formatDuration = (ms) => { + if (ms === 0 || ms == null) return "—"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}m`; +}; + +const formatUptime = (seconds) => { + if (!seconds) return "—"; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +}; + +const WorkerStatusChip = ({ isBusy, currentFunction }) => { + if (isBusy) { + return ( + } + sx={{ maxWidth: 200 }} + /> + ); + } + return } />; +}; + +const UtilizationBar = ({ value }) => ( + + + 80 ? "error" : value > 50 ? "warning" : "primary"} + sx={{ height: 8, borderRadius: 4 }} + /> + + + {value}% + + +); + +const WorkerTable = ({ workers, title }) => { + if (!workers || workers.length === 0) return null; + + return ( + + + + + + + + Worker + Status + Invocations + Utilization + Avg + Min + Max + Last + Alloc + Faults + + + + {workers.map((w) => ( + + + + W{w.WorkerId} + + + + + + {w.TotalInvocations?.toLocaleString() ?? 0} + + + + {formatDuration(w.AvgDurationMs)} + {formatDuration(w.MinDurationMs)} + {formatDuration(w.MaxDurationMs)} + {formatDuration(w.LastDurationMs)} + + + + {w.TotalAllocMB != null ? `${w.TotalAllocMB} MB` : "—"} + + + + + {w.TotalFaults > 0 ? ( + + ) : ( + "0" + )} + + + ))} + +
    +
    +
    +
    + ); +}; + +const TIME_RANGES = [ + { label: "1h", minutes: 60 }, + { label: "6h", minutes: 360 }, + { label: "24h", minutes: 1440 }, + { label: "3d", minutes: 4320 }, + { label: "7d", minutes: 10080 }, +]; + +const formatChartTime = (timestamp, rangeMinutes) => { + const d = new Date(timestamp); + if (rangeMinutes <= 1440) { + return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); + } + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const STARTUP_PHASES = [ + { key: "BaseWorkerMs", label: "Base Worker", fkey: "BaseFunctionCount", color: "#7c4dff" }, + { key: "WarmupMs", label: "Warmup", fkey: null, color: "#ffc107" }, + { key: "HttpReadyMs", label: "HTTP Ready", fkey: "HttpFunctionCount", color: "#00c853" }, + { key: "HttpPoolFullMs", label: "HTTP Pool Full", fkey: null, color: "#69f0ae" }, + { key: "BgReadyMs", label: "BG Ready", fkey: "BgFunctionCount", color: "#29b6f6" }, + { key: "FullyReadyMs", label: "Fully Ready", fkey: null, color: "#66bb6a" }, +]; + +const StartupTimingBar = ({ startup }) => { + if (!startup) return null; + + // Build segments as incremental durations between phases + const phases = STARTUP_PHASES.filter((p) => startup[p.key] > 0); + const totalMs = startup.FullyReadyMs || Math.max(...phases.map((p) => startup[p.key]), 1); + + // Compute incremental segments (each phase = cumulative time to that point) + const segments = phases.map((phase, i) => { + const cumMs = startup[phase.key]; + const prevMs = i > 0 ? startup[phases[i - 1].key] : 0; + const deltaMs = Math.max(cumMs - prevMs, 0); + return { + ...phase, + cumMs, + deltaMs, + pct: totalMs > 0 ? (deltaMs / totalMs) * 100 : 0, + functions: phase.fkey ? startup[phase.fkey] : null, + }; + }); + + return ( + + } + subheader={`${startup.ReadinessMode} / ${startup.WarmupMode} — ${startup.CpuCount} CPUs, ${startup.HttpPoolSize}H + ${startup.BgPoolSize}BG — Total: ${formatDuration(totalMs)}`} + subheaderTypographyProps={{ variant: "caption" }} + sx={{ pb: 0 }} + /> + + {/* Single horizontal stacked bar */} + + {segments.map((seg) => ( + + + {seg.label} + + + {formatDuration(seg.deltaMs)} (cumulative: {formatDuration(seg.cumMs)}) + + {seg.functions != null && ( + + {seg.functions} functions loaded + + )} + + } + > + 8 ? 0 : 4, + cursor: "pointer", + transition: "filter 0.15s", + "&:hover": { filter: "brightness(1.2)" }, + }} + > + {seg.pct > 12 && ( + + {formatDuration(seg.deltaMs)} + + )} + + + ))} + + {/* Legend */} + + {segments.map((seg) => ( + + + + {seg.label} + {seg.functions != null && ` (${seg.functions})`} + + + ))} + + Modules: {startup.SharedModuleCount} shared + {startup.HttpOnlyModuleCount > 0 && `, ${startup.HttpOnlyModuleCount} HTTP`} + {startup.BgOnlyModuleCount > 0 && `, ${startup.BgOnlyModuleCount} BG`} + + + + + ); +}; + +const CompactStatsRow = ({ snapshot }) => { + if (!snapshot) return null; + + const http = snapshot.HttpPool || {}; + const bg = snapshot.BgPool || {}; + const jobs = snapshot.Jobs || {}; + const limiter = snapshot.Limiter || {}; + const mem = snapshot.Memory || {}; + + const sections = [ + { + label: "HTTP Pool", + color: "primary", + stats: [ + { k: "Size", v: http.PoolSize ?? 0 }, + { k: "Busy", v: http.BusyCount ?? 0, w: http.BusyCount >= http.PoolSize }, + { k: "Invocations", v: http.TotalInvocations?.toLocaleString() ?? 0 }, + { k: "Util", v: `${http.AvgUtilizationPct ?? 0}%`, w: http.AvgUtilizationPct > 80 }, + { k: "Avg", v: formatDuration(http.AvgDurationMs) }, + { k: "Faults", v: http.TotalFaults ?? 0, w: http.TotalFaults > 0 }, + ], + }, + { + label: "BG Pool", + color: "warning", + stats: [ + { k: "Size", v: bg.PoolSize ?? 0 }, + { k: "Busy", v: bg.BusyCount ?? 0, w: bg.BusyCount >= bg.PoolSize }, + { k: "Invocations", v: bg.TotalInvocations?.toLocaleString() ?? 0 }, + { k: "Util", v: `${bg.AvgUtilizationPct ?? 0}%`, w: bg.AvgUtilizationPct > 80 }, + { k: "Avg", v: formatDuration(bg.AvgDurationMs) }, + { k: "Faults", v: bg.TotalFaults ?? 0, w: bg.TotalFaults > 0 }, + ], + }, + { + label: "Jobs", + color: "info", + stats: [ + { k: "Running", v: jobs.Running ?? 0 }, + { k: "Queued", v: jobs.Queued ?? 0, w: jobs.Queued > 10 }, + { k: "Done", v: jobs.Completed?.toLocaleString() ?? 0 }, + { k: "Failed", v: jobs.Failed ?? 0, w: jobs.Failed > 0 }, + ], + }, + { + label: "Limiter", + color: "default", + stats: [ + { k: "Active", v: `${limiter.Active ?? 0} / ${limiter.CurrentMax ?? 0}` }, + { k: "Waiting", v: limiter.Waiting ?? 0 }, + ...(limiter.IsHttpThrottled ? [{ k: "Status", v: "Throttled", w: true }] : []), + ], + }, + { + label: "Memory", + color: "secondary", + stats: [ + { k: "Container", v: `${mem.ContainerUsedMB ?? mem.RssMB ?? 0} / ${mem.ContainerLimitMB ?? 0}MB`, w: mem.UsagePct > 85 }, + { k: "App RSS", v: `${mem.RssMB ?? 0}MB` }, + { k: "Other", v: `${mem.OtherRssMB ?? 0}MB` }, + { k: "GC Heap", v: `${mem.HeapMB ?? 0}MB` }, + { k: "Committed", v: `${mem.CommittedMB ?? 0}MB` }, + { k: "GC Limit", v: `${mem.GCHeapLimitMB ?? 0}MB` }, + { k: "Usage", v: `${mem.UsagePct ?? 0}%`, w: mem.UsagePct > 85 }, + { k: "GC", v: `${mem.GC0 ?? 0}/${mem.GC1 ?? 0}/${mem.GC2 ?? 0}` }, + ...(mem.TestDataCacheCount != null ? [{ k: "Cache", v: `${mem.TestDataCacheCount} entries` }] : []), + ], + }, + { + label: "CPU", + color: "warning", + stats: [ + { k: "Container", v: `${mem.ContainerCpuPct ?? mem.CpuPct ?? 0}%`, w: (mem.ContainerCpuPct ?? 0) > 80 }, + { k: "App", v: `${mem.CpuPct ?? 0}%`, w: mem.CpuPct > 80 }, + { k: "Other", v: `${mem.OtherCpuPct ?? 0}%` }, + ], + }, + ]; + + return ( + + + + + + {sections.map((sec) => ( + + + + + {sec.stats.map((s) => ( + + + {s.k} + + + {s.v} + + + ))} + {/* Pad empty cells so columns stay aligned */} + {Array.from({ length: Math.max(0, 6 - sec.stats.length) }).map((_, i) => ( + + ))} + + ))} + +
    +
    +
    +
    + ); +}; + +const HistoryChart = ({ data, rangeMinutes, title, icon, children }) => { + const theme = useTheme(); + + if (!data || data.length === 0) { + return ( + + + + + + No historical data available yet — data collection starts after 60 seconds + + + + + ); + } + + return ( + + + + + + {children(data, theme)} + + + + + ); +}; + +const Page = () => { + const theme = useTheme(); + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + const [historyRange, setHistoryRange] = useState(60); + const [paused, setPaused] = useState(false); + const [importedData, setImportedData] = useState(null); + const [jobLimit, setJobLimit] = useState(2000); + + const isImported = importedData !== null; + const effectivePaused = paused || isImported; + + const healthQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "Snapshot" }, + queryKey: "WorkerHealth", + refetchInterval: effectivePaused ? false : 5000, + }); + + const startupQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "Startup" }, + queryKey: "WorkerStartup", + }); + + const historyQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "History", Minutes: String(historyRange), MaxPoints: "500" }, + queryKey: `WorkerHistory-${historyRange}`, + refetchInterval: effectivePaused ? false : 60000, + }); + + const jobAction = ApiPostCall({ + relatedQueryKeys: ["WorkerHealthJobs", "WorkerHealth"], + }); + + const cacheDiagQuery = ApiGetCall({ + url: "/api/ListWorkerHealth", + data: { Action: "CacheDiag" }, + queryKey: "WorkerCacheDiag", + refetchInterval: effectivePaused ? false : 30000, + }); + + // Resolve data: imported overrides live + const snapshot = isImported ? importedData.snapshot : healthQuery.data?.Results; + const startupInfo = isImported ? importedData.startup : startupQuery.data?.Results; + const importedJobs = useMemo(() => { + if (!isImported || !importedData.jobs) return null; + // Handle both array and { Results: [...] } shapes from query cache + if (Array.isArray(importedData.jobs)) return importedData.jobs; + if (Array.isArray(importedData.jobs?.Results)) return importedData.jobs.Results; + if (Array.isArray(importedData.jobs?.data?.Results)) return importedData.jobs.data.Results; + if (Array.isArray(importedData.jobs?.data)) return importedData.jobs.data; + return []; + }, [isImported, importedData]); + + const historyData = useMemo(() => { + const raw = isImported + ? importedData.history?.Data ?? importedData.history + : historyQuery.data?.Results?.Data; + if (!raw || !Array.isArray(raw)) return []; + return raw.map((p) => ({ + ...p, + time: formatChartTime(p.TimestampUtc, isImported ? importedData.historyRange ?? 60 : historyRange), + })); + }, [historyQuery.data, historyRange, importedData, isImported]); + + // ── Export ── + const handleExport = useCallback(() => { + const payload = { + exportedAt: new Date().toISOString(), + historyRange, + snapshot: healthQuery.data?.Results ?? null, + startup: startupQuery.data?.Results ?? null, + history: historyQuery.data?.Results ?? null, + jobs: null, + }; + // Try to grab current job data from query cache + // CippDataTable may store the key with extra params, so search by prefix + const allQueries = queryClient.getQueriesData({ queryKey: ["WorkerHealthJobs"] }); + for (const [, data] of allQueries) { + if (data) { + const rows = data?.Results ?? data?.data?.Results ?? data; + if (Array.isArray(rows)) { + payload.jobs = rows; + break; + } + } + } + + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `worker-health-${new Date().toISOString().slice(0, 16).replace(/:/g, "")}.json`; + a.click(); + URL.revokeObjectURL(url); + }, [healthQuery.data, startupQuery.data, historyQuery.data, historyRange, queryClient]); + + // ── Import ── + const handleImport = useCallback((event) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target.result); + setImportedData(data); + setPaused(true); + } catch { + // invalid JSON — ignore + } + }; + reader.readAsText(file); + // Reset input so same file can be re-imported + event.target.value = ""; + }, []); + + const handleClearImport = useCallback(() => { + setImportedData(null); + setPaused(false); + }, []); + + const handleRefreshHistory = useCallback(() => { + queryClient.invalidateQueries({ queryKey: [`WorkerHistory-${historyRange}`] }); + }, [queryClient, historyRange]); + + const infoBarData = useMemo(() => { + if (!snapshot) return []; + const http = snapshot.HttpPool || {}; + const bg = snapshot.BgPool || {}; + const jobs = snapshot.Jobs || {}; + const limiter = snapshot.Limiter || {}; + + return [ + { + icon: , + name: "HTTP Workers", + data: `${http.BusyCount ?? 0} / ${http.PoolSize ?? 0} busy`, + color: http.BusyCount >= http.PoolSize ? "error" : "primary", + }, + { + icon: , + name: "BG Workers", + data: `${bg.BusyCount ?? 0} / ${bg.PoolSize ?? 0} busy`, + color: bg.BusyCount >= bg.PoolSize ? "error" : "primary", + }, + { + icon: jobs.Running > 0 ? : , + name: "Job Queue", + data: `${jobs.Running ?? 0} running, ${jobs.Queued ?? 0} queued`, + color: jobs.Queued > 10 ? "warning" : "primary", + }, + { + icon: limiter.IsHttpThrottled ? : , + name: "BG Limiter", + data: limiter.IsHttpThrottled + ? "HTTP Throttled" + : `${limiter.Active ?? 0} / ${limiter.CurrentMax ?? 0} active`, + color: limiter.IsHttpThrottled ? "error" : "primary", + }, + { + icon: , + name: "Memory", + data: `${snapshot.Memory?.ContainerUsedMB ?? snapshot.Memory?.RssMB ?? 0}MB / ${snapshot.Memory?.ContainerLimitMB ?? 0}MB (${snapshot.Memory?.UsagePct ?? 0}%)`, + color: (snapshot.Memory?.UsagePct ?? 0) > 85 ? "error" : (snapshot.Memory?.UsagePct ?? 0) > 70 ? "warning" : "primary", + }, + { + icon: , + name: "CPU", + data: `${snapshot.Memory?.ContainerCpuPct ?? snapshot.Memory?.CpuPct ?? 0}% container / ${snapshot.Memory?.CpuPct ?? 0}% app`, + color: (snapshot.Memory?.ContainerCpuPct ?? snapshot.Memory?.CpuPct ?? 0) > 80 ? "error" : (snapshot.Memory?.ContainerCpuPct ?? snapshot.Memory?.CpuPct ?? 0) > 50 ? "warning" : "primary", + }, + ]; + }, [snapshot]); + + const jobSimpleColumns = ["Name", "RunName", "Priority", "Status", "QueuedUtc", "WaitSeconds", "DurationSeconds"]; + + const jobActions = useMemo( + () => [ + { + label: "Cancel Job", + icon: , + color: "error.main", + noConfirm: true, + customFunction: (row) => { + jobAction.mutate({ + url: "/api/ListWorkerHealth", + data: { Action: "CancelJob", JobId: row.Id }, + }); + }, + condition: (row) => row.Status === "Queued", + }, + { + label: "Change Priority", + icon: , + fields: [ + { + type: "textField", + name: "Priority", + label: "New Priority (0 = highest)", + }, + ], + url: "/api/ListWorkerHealth", + data: { Action: "ChangePriority" }, + dataFunction: (row, formData) => ({ + Action: "ChangePriority", + JobId: row.Id, + Priority: parseInt(formData.Priority, 10), + }), + confirmText: "Change", + condition: (row) => row.Status === "Queued", + relatedQueryKeys: ["WorkerHealthJobs", "WorkerHealth"], + }, + { + label: "Cancel Run", + icon: , + color: "error.main", + noConfirm: true, + customFunction: (row) => { + if (row.RunName) { + jobAction.mutate({ + url: "/api/ListWorkerHealth", + data: { Action: "CancelRun", RunName: row.RunName }, + }); + } + }, + condition: (row) => row.Status === "Queued" && row.RunName, + }, + { + label: "Delete", + icon: , + noConfirm: true, + customFunction: (row) => { + jobAction.mutate({ + url: "/api/ListWorkerHealth", + data: { Action: "DeleteJob", JobId: row.Id }, + }); + }, + condition: (row) => row.Status !== "Queued" && row.Status !== "Running", + }, + ], + [jobAction] + ); + + const jobFilters = useMemo( + () => [ + { filterName: "Queued", value: [{ id: "Status", value: "Queued" }] }, + { filterName: "Running", value: [{ id: "Status", value: "Running" }] }, + { filterName: "Failed", value: [{ id: "Status", value: "Failed" }] }, + ], + [] + ); + + return ( + <> + + Worker Health | CIPP + + + + + {/* ── Header toolbar ── */} + + Worker Health + + {isImported && ( + } + /> + )} + {!isImported && healthQuery.isFetching && } + {!isImported && snapshot && ( + + Uptime: {formatUptime(snapshot.UptimeSeconds)} + + )} + + setPaused((p) => !p)} + color={effectivePaused ? "warning" : "default"} + disabled={isImported} + > + {effectivePaused ? : } + + + + + + + + + fileInputRef.current?.click()}> + + + + + + + + {/* ── KPI bar ── */} + + + {/* ── Compact pool / jobs / limiter stats ── */} + + + {/* ── Worker tables ── */} + + + + {/* ── Job Queue ── */} + {isImported && importedJobs ? ( + + + + {importedJobs.length === 0 ? ( + + + No job data was captured in this export + + + ) : ( + + + + + {jobSimpleColumns.map((col) => ( + {col} + ))} + + + + {importedJobs.slice(0, 200).map((row, i) => ( + + {jobSimpleColumns.map((col) => ( + + {row[col] != null ? String(row[col]) : "—"} + + ))} + + ))} + +
    +
    + )} +
    +
    + ) : ( + val !== null && setJobLimit(val)} + size="small" + > + {[500, 2000, 5000, 10000].map((n) => ( + + {n >= 1000 ? `${n / 1000}k` : n} + + ))} + + } + /> + )} + + {/* ── Historical Trends header with controls ── */} + + } + action={ + + {!isImported && ( + + + + + + )} + val !== null && setHistoryRange(val)} + size="small" + disabled={isImported} + > + {TIME_RANGES.map((r) => ( + + {r.label} + + ))} + + + } + /> + + + + + } + > + {(data, t) => ( + + + + + + + + + + )} + + + + } + > + {(data, t) => ( + + + + + + + + + + )} + + + + + + + } + > + {(data, t) => ( + + + + + + + + + + + + + )} + + + + } + > + {(data, t) => ( + + + + + + + + + + + )} + + + + + + + } + > + {(data, t) => ( + + + + + + + + + + )} + + + + } + > + {(data, t) => ( + + + + + + + + + + + + + )} + + + + + {/* ── TestData Cache Diagnostics ── */} + {(() => { + const diag = cacheDiagQuery.data?.Results; + if (!diag) return null; + const types = diag.TypeBreakdown ?? []; + const trackedMB = diag.TrackedTotalMB ?? 0; + const maxMB = diag.MaxMB ?? 0; + const memPct = maxMB > 0 ? (trackedMB / maxMB) * 100 : 0; + const totalReads = (diag.Hits ?? 0) + (diag.Misses ?? 0); + const fmtUtc = (s) => (s ? new Date(s).toLocaleString() : "—"); + + const cacheStats = [ + { k: "Hits", v: (diag.Hits ?? 0).toLocaleString() }, + { k: "Misses", v: (diag.Misses ?? 0).toLocaleString() }, + { + k: "Hit Rate", + v: `${diag.HitRate ?? 0}%`, + w: totalReads > 100 && diag.HitRate < 50, + }, + { + k: "Evictions", + v: (diag.Evictions ?? 0).toLocaleString(), + w: (diag.Evictions ?? 0) > 0, + }, + { + k: "Oversized", + v: (diag.Oversized ?? 0).toLocaleString(), + w: (diag.Oversized ?? 0) > 0, + tip: "Values that exceeded the per-entry size cap and were silently dropped — they were never cached.", + }, + { k: "Accesses", v: (diag.AccessCount ?? 0).toLocaleString() }, + { + k: "TTL", + v: `${diag.TtlSeconds ?? 0}s`, + tip: `Earliest expiry: ${fmtUtc(diag.EarliestExpiryUtc)} • Latest expiry: ${fmtUtc(diag.LatestExpiryUtc)}`, + }, + ]; + + return ( + + 5000 ? "error" : diag.TotalEntries > 1000 ? "warning" : "success"} + size="small" + /> + } + /> + 0 ? 2 : "12px !important" }}> + {/* Capacity bar */} + + + + Capacity + + + {trackedMB} / {maxMB} MB ({Math.round(memPct)}%) + + + 85 ? "error" : memPct > 70 ? "warning" : "primary"} + sx={{ height: 6, borderRadius: 3 }} + /> + + {/* Stats row */} + + {cacheStats.map((s) => { + const cell = ( + + + {s.k} + + + {s.v} + + + ); + if (s.tip) { + return ( + + {cell} + + ); + } + return {cell}; + })} + + + {types.length > 0 && ( + + + + + + Data Type + Tenants + Items + Est. MB + + + + {types.map((t) => ( + + {t.Type} + {t.EntryCount} + {t.TotalItems?.toLocaleString()} + {t.TotalMB} + + ))} + +
    +
    +
    + )} +
    + ); + })()} + + {/* ── Startup Timing (bottom) ── */} + +
    +
    +
    + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/cipp/custom-data/tabOptions.json b/src/pages/cipp/custom-data/tabOptions.json index de0055483d82..bc87adf4dc60 100644 --- a/src/pages/cipp/custom-data/tabOptions.json +++ b/src/pages/cipp/custom-data/tabOptions.json @@ -1,14 +1,17 @@ [ { "label": "Directory Extensions", - "path": "/cipp/custom-data/directory-extensions" + "path": "/cipp/custom-data/directory-extensions", + "icon": "Dns" }, { "label": "Schema Extensions", - "path": "/cipp/custom-data/schema-extensions" + "path": "/cipp/custom-data/schema-extensions", + "icon": "Description" }, { "label": "Mappings", - "path": "/cipp/custom-data/mappings" + "path": "/cipp/custom-data/mappings", + "icon": "Share" } ] diff --git a/src/pages/cipp/integrations/index.js b/src/pages/cipp/integrations/index.js index 60ee764853b4..a60530323c46 100644 --- a/src/pages/cipp/integrations/index.js +++ b/src/pages/cipp/integrations/index.js @@ -68,45 +68,83 @@ const Page = () => { status = 'Enabled' } - return ( - - - + {extension.comingSoon && ( + + Coming Soon + + )} + + - - {extension?.logo && ( + {extension?.logo && ( + + )} + + + {extension.description} + + +
    + + + {extension.comingSoon ? ( + <> - )} - - {extension.description} - - -
    - - + Coming Soon Through Third-Party + + ) : ( + <> {integrations.isSuccess ? ( { {integrations.isSuccess ? status : 'Loading'} - - - - + + )} + + + + ) + + return ( + + {extension.comingSoon ? ( + cardContent + ) : ( + + {cardContent} + + )} ) })} diff --git a/src/pages/cipp/settings/features.js b/src/pages/cipp/settings/features.js index 15b6fd3a111e..d630e93eb2ba 100644 --- a/src/pages/cipp/settings/features.js +++ b/src/pages/cipp/settings/features.js @@ -59,6 +59,7 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} tenantInTitle={false} + dataFilter={(row) => !row.Hidden} /> ); }; diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index d734f7eac437..d8b033cbdbd1 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -1,28 +1,28 @@ -import tabOptions from "./tabOptions"; -import { TabbedLayout } from "../../../layouts/TabbedLayout"; -import { Layout as DashboardLayout } from "../../../layouts/index.js"; -import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; -import { Button, SvgIcon, Stack, Box } from "@mui/material"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { Add, RestartAlt } from "@mui/icons-material"; -import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; -import { useDialog } from "../../../hooks/use-dialog"; -import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; -import { CippFormCondition } from "../../../components/CippComponents/CippFormCondition"; -import M365LicensesDefault from "../../../data/M365Licenses.json"; -import M365LicensesAdditional from "../../../data/M365Licenses-additional.json"; -import { useMemo, useCallback } from "react"; +import tabOptions from './tabOptions' +import { TabbedLayout } from '../../../layouts/TabbedLayout' +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { CippTablePage } from '../../../components/CippComponents/CippTablePage.jsx' +import { Button, SvgIcon, Stack, Box } from '@mui/material' +import { TrashIcon } from '@heroicons/react/24/outline' +import { Add, RestartAlt, NotificationsOff } from '@mui/icons-material' +import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog' +import { useDialog } from '../../../hooks/use-dialog' +import CippFormComponent from '../../../components/CippComponents/CippFormComponent' +import { CippFormCondition } from '../../../components/CippComponents/CippFormCondition' +import M365LicensesDefault from '../../../data/M365Licenses.json' +import M365LicensesAdditional from '../../../data/M365Licenses-additional.json' +import { useMemo, useCallback } from 'react' const Page = () => { - const pageTitle = "Excluded Licenses"; - const apiUrl = "/api/ListExcludedLicenses"; - const createDialog = useDialog(); - const resetDialog = useDialog(); - const simpleColumns = ["Product_Display_Name", "GUID"]; + const pageTitle = 'Excluded Licenses' + const apiUrl = '/api/ListExcludedLicenses' + const createDialog = useDialog() + const resetDialog = useDialog() + const simpleColumns = ['Product_Display_Name', 'GUID', 'ExclusionType'] const allLicenseOptions = useMemo(() => { - const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional]; - const uniqueLicenses = new Map(); + const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional] + const uniqueLicenses = new Map() allLicenses.forEach((license) => { if (license.GUID && license.Product_Display_Name) { @@ -30,39 +30,48 @@ const Page = () => { uniqueLicenses.set(license.GUID, { label: license.Product_Display_Name, value: license.GUID, - }); + }) } } - }); + }) - const options = Array.from(uniqueLicenses.values()); - const nameCounts = {}; + const options = Array.from(uniqueLicenses.values()) + const nameCounts = {} options.forEach((opt) => { - nameCounts[opt.label] = (nameCounts[opt.label] || 0) + 1; - }); + nameCounts[opt.label] = (nameCounts[opt.label] || 0) + 1 + }) return options .map((opt) => nameCounts[opt.label] > 1 ? { ...opt, label: `${opt.label} (${opt.value})` } : opt ) - .sort((a, b) => a.label.localeCompare(b.label)); - }, []); + .sort((a, b) => a.label.localeCompare(b.label)) + }, []) const actions = [ { - label: "Delete Exclusion", - type: "POST", - url: "/api/ExecExcludeLicenses", - data: { Action: "!RemoveExclusion", GUID: "GUID" }, - confirmText: "Do you want to delete this exclusion?", - color: "error", + label: 'Only Exclude from Alerts', + type: 'POST', + url: '/api/ExecExcludeLicenses', + data: { Action: '!AlertOnly', GUID: 'GUID', SKUName: 'Product_Display_Name' }, + confirmText: + 'This license will remain visible in CIPP but will be excluded from alerts. Continue?', + icon: , + }, + { + label: 'Delete Exclusion', + type: 'POST', + url: '/api/ExecExcludeLicenses', + data: { Action: '!RemoveExclusion', GUID: 'GUID' }, + confirmText: 'Do you want to delete this exclusion?', + color: 'error', icon: ( ), }, - ]; + ] const CardButtons = () => { return ( @@ -90,28 +99,28 @@ const Page = () => { Restore Defaults - ); - }; + ) + } const offCanvas = { - extendedInfoFields: ["Product_Display_Name", "GUID"], + extendedInfoFields: ['Product_Display_Name', 'GUID', 'ExclusionType'], actions: actions, - }; + } const addExclusionFormatter = useCallback((row, action, formData) => { if (formData.advancedMode) { return { - Action: "AddExclusion", + Action: 'AddExclusion', GUID: formData.GUID, SKUName: formData.SKUName, - }; + } } return { - Action: "AddExclusion", + Action: 'AddExclusion', GUID: formData.selectedLicense?.value, SKUName: formData.selectedLicense?.label, - }; - }, []); + } + }, []) return ( <> @@ -130,13 +139,13 @@ const Page = () => { title="Add Excluded License" createDialog={createDialog} api={{ - url: "/api/ExecExcludeLicenses", + url: '/api/ExecExcludeLicenses', confirmText: - "Add a license to the exclusion table. Select from the list or use Advanced Mode to enter a custom GUID.", - type: "POST", - data: { Action: "!AddExclusion" }, - replacementBehaviour: "removeNulls", - relatedQueryKeys: ["ExcludedLicenses"], + 'Add a license to the exclusion table. Select from the list or use Advanced Mode to enter a custom GUID.', + type: 'POST', + data: { Action: '!AddExclusion' }, + replacementBehaviour: 'removeNulls', + relatedQueryKeys: ['ExcludedLicenses'], customDataformatter: addExclusionFormatter, }} > @@ -165,7 +174,7 @@ const Page = () => { formControl={formHook} multiple={false} creatable={false} - validators={{ required: "Please select a license" }} + validators={{ required: 'Please select a license' }} /> @@ -182,7 +191,7 @@ const Page = () => { label="GUID" formControl={formHook} disableVariables={true} - validators={{ required: "GUID is required" }} + validators={{ required: 'GUID is required' }} /> { label="SKU Name" formControl={formHook} disableVariables={true} - validators={{ required: "SKU Name is required" }} + validators={{ required: 'SKU Name is required' }} /> @@ -202,28 +211,28 @@ const Page = () => { createDialog={resetDialog} fields={[ { - type: "switch", - name: "FullReset", - label: "Full Reset (clear all entries including manually added ones)", + type: 'switch', + name: 'FullReset', + label: 'Full Reset (clear all entries including manually added ones)', }, ]} api={{ - url: "/api/ExecExcludeLicenses", + url: '/api/ExecExcludeLicenses', confirmText: "This will restore default licenses from the config file. If 'Full Reset' is enabled, all existing entries will be cleared first.", - type: "POST", - data: { Action: "!RestoreDefaults" }, - relatedQueryKeys: ["ExcludedLicenses"], + type: 'POST', + data: { Action: '!RestoreDefaults' }, + relatedQueryKeys: ['ExcludedLicenses'], }} /> - ); -}; + ) +} Page.getLayout = (page) => ( {page} -); +) -export default Page; +export default Page diff --git a/src/pages/cipp/settings/tabOptions.json b/src/pages/cipp/settings/tabOptions.json index 143f94ffbf49..da85db4cab73 100644 --- a/src/pages/cipp/settings/tabOptions.json +++ b/src/pages/cipp/settings/tabOptions.json @@ -1,38 +1,47 @@ [ { "label": "General", - "path": "/cipp/settings" + "path": "/cipp/settings", + "icon": "Settings" }, { "label": "Permissions", - "path": "/cipp/settings/permissions" + "path": "/cipp/settings/permissions", + "icon": "Key" }, { "label": "Tenants", - "path": "/cipp/settings/tenants" + "path": "/cipp/settings/tenants", + "icon": "Domain" }, { "label": "Backend", - "path": "/cipp/settings/backend" + "path": "/cipp/settings/backend", + "icon": "Cloud" }, { "label": "Notifications", - "path": "/cipp/settings/notifications" + "path": "/cipp/settings/notifications", + "icon": "Notifications" }, { "label": "Automated Onboarding", - "path": "/cipp/settings/partner-webhooks" + "path": "/cipp/settings/partner-webhooks", + "icon": "AutoMode" }, { "label": "Licenses", - "path": "/cipp/settings/licenses" + "path": "/cipp/settings/licenses", + "icon": "Assignment" }, { "label": "Features", - "path": "/cipp/settings/features" + "path": "/cipp/settings/features", + "icon": "Apps" }, { "label": "SIEM", - "path": "/cipp/settings/siem" + "path": "/cipp/settings/siem", + "icon": "Security" } -] \ No newline at end of file +] diff --git a/src/pages/dashboardv2/index.js b/src/pages/dashboardv2/index.js index 0ea9653e3680..48e3bd3b3b62 100644 --- a/src/pages/dashboardv2/index.js +++ b/src/pages/dashboardv2/index.js @@ -194,8 +194,12 @@ const Page = () => { - + { )} - + @@ -319,12 +323,12 @@ const Page = () => { {/* Tenant Overview Section - 3 Column Layout */} {/* Column 1: Tenant Information */} - + {/* Column 2: Tenant Metrics - 2x3 Grid */} - + { {/* Column 3: Assessment Results */} - + { {/* Left Column */} - + - + { {/* Right Column */} - + - + { return ( { return ( { + const pageTitle = 'HVE Accounts' + + const reportDB = useCippReportDB({ + apiUrl: '/api/ListHVEAccounts', + queryKey: 'ListHVEAccounts', + cacheName: 'HVEAccounts', + syncTitle: 'Sync HVE Accounts', + allowToggle: true, + defaultCached: true, + }) + + const actions = [ + { + label: 'Edit Display Name', + type: 'POST', + url: '/api/ExecHVEUser', + icon: , + data: { Identity: 'primarySmtpAddress', Action: 'Edit' }, + fields: [ + { + type: 'textField', + name: 'DisplayName', + label: 'Display Name', + }, + ], + confirmText: 'Update display name for [primarySmtpAddress]', + hideBulk: true, + }, + { + label: 'Set Reply-To Address', + type: 'POST', + url: '/api/ExecHVEUser', + icon: , + data: { Identity: 'primarySmtpAddress', Action: 'Edit' }, + fields: [ + { + type: 'textField', + name: 'ReplyTo', + label: 'Reply-To Address', + placeholder: 'e.g. replies@contoso.com (leave empty to clear)', + }, + ], + confirmText: 'Update reply-to address for [primarySmtpAddress]', + hideBulk: true, + }, + { + label: 'Change Primary SMTP Address', + type: 'POST', + url: '/api/ExecHVEUser', + icon: , + data: { Identity: 'primarySmtpAddress', Action: 'Edit' }, + fields: [ + { + type: 'textField', + name: 'username', + label: 'Username (local part)', + placeholder: 'e.g. hveaccount01', + }, + { + type: 'autoComplete', + name: 'domain', + label: 'Domain', + api: { + url: '/api/ListGraphRequest', + dataKey: 'Results', + queryKey: 'listDomains-hve', + labelField: (option) => option.id, + valueField: 'id', + addedField: { + isDefault: 'isDefault', + isVerified: 'isVerified', + }, + data: { + Endpoint: 'domains', + manualPagination: true, + $count: true, + $top: 99, + }, + dataFilter: (domains) => + domains + .filter((d) => d?.addedFields?.isVerified === true) + .sort((a, b) => { + if (a.addedFields?.isDefault === true) return -1 + if (b.addedFields?.isDefault === true) return 1 + return 0 + }), + }, + }, + ], + confirmText: 'Change primary SMTP address for [primarySmtpAddress]', + hideBulk: true, + }, + { + label: 'Assign Billing Policy', + type: 'POST', + url: '/api/ExecHVEUser', + icon: , + data: { Identity: 'primarySmtpAddress', Action: 'AssignBillingPolicy' }, + fields: [ + { + type: 'autoComplete', + name: 'BillingPolicyId', + label: 'Billing Policy', + multiple: false, + api: { + url: '/api/ListHVEAccounts', + queryKey: 'ListHVEBillingPolicies', + labelField: (option) => + `${option.Name || option.BillingPolicyName || option.BillingPolicyId} (${option.BillingPolicyId || option.Guid || option.Identity})`, + valueField: (option) => option.BillingPolicyId || option.Guid || option.Identity, + data: { + ListBillingPolicies: true, + }, + }, + }, + ], + confirmText: 'Assign billing policy to [primarySmtpAddress]. Current policy: [BillingPolicyName]', + hideBulk: true, + }, + { + label: 'Remove Billing Policy', + type: 'POST', + url: '/api/ExecHVEUser', + icon: , + data: { Identity: 'primarySmtpAddress', Action: 'RemoveBillingPolicy' }, + confirmText: + 'Remove billing policy [BillingPolicyName] from [primarySmtpAddress]?', + condition: (row) => row.BillingPolicyName && row.BillingPolicyName !== 'None', + hideBulk: true, + }, + { + label: 'Delete HVE Account', + type: 'POST', + icon: , + url: '/api/ExecHVEUser', + data: { Identity: 'primarySmtpAddress', Action: 'Remove' }, + confirmText: 'Are you sure you want to delete HVE account [primarySmtpAddress]?', + multiPost: false, + }, + ] + + const offCanvas = { + extendedInfoFields: [ + 'displayName', + 'primarySmtpAddress', + 'Alias', + 'AdditionalEmailAddresses', + 'BillingPolicyName', + 'BillingPolicyId', + 'WhenCreated', + 'ExternalDirectoryObjectId', + ], + actions: actions, + } + + const simpleColumns = [ + ...reportDB.cacheColumns.filter((c) => c === 'Tenant'), + 'displayName', + 'primarySmtpAddress', + 'Alias', + 'WhenCreated', + 'AdditionalEmailAddresses', + ...reportDB.cacheColumns.filter((c) => c !== 'Tenant'), + ] + + return ( + <> + + + {reportDB.controls} + + } + /> + {reportDB.syncDialog} + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/email/administration/mailbox-rules/index.js b/src/pages/email/administration/mailbox-rules/index.js index 4b9a9ece88cb..98f0d076caff 100644 --- a/src/pages/email/administration/mailbox-rules/index.js +++ b/src/pages/email/administration/mailbox-rules/index.js @@ -1,90 +1,97 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { getCippTranslation } from "../../../../utils/get-cipp-translation"; -import { getCippFormatting } from "../../../../utils/get-cipp-formatting"; -import { CippPropertyListCard } from "../../../../components/CippCards/CippPropertyListCard"; -import { Block, PlayArrow, DeleteForever } from "@mui/icons-material"; -import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { getCippTranslation } from '../../../../utils/get-cipp-translation' +import { getCippFormatting } from '../../../../utils/get-cipp-formatting' +import { CippPropertyListCard } from '../../../../components/CippCards/CippPropertyListCard' +import { Block, PlayArrow, DeleteForever } from '@mui/icons-material' +import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' const Page = () => { - const pageTitle = "Mailbox Rules"; + const pageTitle = 'Mailbox Rules' const reportDB = useCippReportDB({ - apiUrl: "/api/ListMailboxRules", - queryKey: "ListMailboxRules", - cacheName: "Mailboxes", - syncTitle: "Sync Mailbox Rules", - syncData: { Types: "Rules" }, + apiUrl: '/api/ListMailboxRules', + queryKey: 'ListMailboxRules', + cacheName: 'Mailboxes', + syncTitle: 'Sync Mailbox Rules', + syncData: { Types: 'Rules' }, allowToggle: false, defaultCached: true, - }); + }) const simpleColumns = [ - ...reportDB.cacheColumns.filter((c) => c === "Tenant"), - "UserPrincipalName", - "Name", - "Priority", - "Enabled", - "From", - ...reportDB.cacheColumns.filter((c) => c !== "Tenant"), - ]; + ...reportDB.cacheColumns.filter((c) => c === 'Tenant'), + 'UserPrincipalName', + 'Name', + 'Priority', + 'Enabled', + 'From', + ...reportDB.cacheColumns.filter((c) => c !== 'Tenant'), + ] const actions = [ { - label: "Enable Mailbox Rule", - type: "POST", + label: 'Enable Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecSetMailboxRule", + url: '/api/ExecSetMailboxRule', data: { - ruleId: "Identity", - userPrincipalName: "OperationGuid", - ruleName: "Name", + ruleId: 'Identity', + userPrincipalName: 'OperationGuid', + ruleName: 'Name', Enable: true, + tenantFilter: 'Tenant', }, condition: (row) => !row.Enabled, - confirmText: "Are you sure you want to enable this mailbox rule?", + confirmText: 'Are you sure you want to enable this mailbox rule?', multiPost: false, }, { - label: "Disable Mailbox Rule", - type: "POST", + label: 'Disable Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecSetMailboxRule", + url: '/api/ExecSetMailboxRule', data: { - ruleId: "Identity", - userPrincipalName: "OperationGuid", - ruleName: "Name", + ruleId: 'Identity', + userPrincipalName: 'OperationGuid', + ruleName: 'Name', Disable: true, + tenantFilter: 'Tenant', }, condition: (row) => row.Enabled, - confirmText: "Are you sure you want to disable this mailbox rule?", + confirmText: 'Are you sure you want to disable this mailbox rule?', multiPost: false, }, { - label: "Remove Mailbox Rule", - type: "POST", + label: 'Remove Mailbox Rule', + type: 'POST', icon: , - url: "/api/ExecRemoveMailboxRule", - data: { ruleId: "Identity", userPrincipalName: "OperationGuid", ruleName: "Name" }, - confirmText: "Are you sure you want to remove this mailbox rule?", + url: '/api/ExecRemoveMailboxRule', + data: { + ruleId: 'Identity', + userPrincipalName: 'OperationGuid', + ruleName: 'Name', + tenantFilter: 'Tenant', + }, + confirmText: 'Are you sure you want to remove this mailbox rule?', multiPost: false, }, - ]; + ] const offCanvas = { children: (data) => { const keys = Object.keys(data).filter( - (key) => !key.includes("@odata") && !key.includes("@data"), - ); - const properties = []; + (key) => !key.includes('@odata') && !key.includes('@data') + ) + const properties = [] keys.forEach((key) => { if (data[key] && data[key].length > 0) { properties.push({ label: getCippTranslation(key), value: getCippFormatting(data[key], key), - }); + }) } - }); + }) return ( { actionItems={actions} data={data} /> - ); + ) }, - }; + } return ( <> @@ -111,8 +118,8 @@ const Page = () => { /> {reportDB.syncDialog} - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/email/administration/mailboxes/index.js b/src/pages/email/administration/mailboxes/index.js index f0187d414983..4f689548fabf 100644 --- a/src/pages/email/administration/mailboxes/index.js +++ b/src/pages/email/administration/mailboxes/index.js @@ -1,7 +1,6 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' import CippExchangeActions from '../../../../components/CippComponents/CippExchangeActions' -import { CippHVEUserDrawer } from '../../../../components/CippComponents/CippHVEUserDrawer.jsx' import { CippSharedMailboxDrawer } from '../../../../components/CippComponents/CippSharedMailboxDrawer.jsx' import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' import { Stack } from '@mui/system' @@ -45,6 +44,11 @@ const Page = () => { value: [{ id: 'recipientTypeDetails', value: 'EquipmentMailbox' }], type: 'column', }, + { + filterName: 'View Archive-Enabled Mailboxes', + value: [{ id: 'ArchiveEnabled', value: true }], + type: 'column', + }, ] // Simplified columns for the table @@ -55,6 +59,9 @@ const Page = () => { 'UPN', 'primarySmtpAddress', 'AdditionalEmailAddresses', + ...(reportDB.useReportDB ? ['storageUsedInBytes'] : []), + 'ArchiveEnabled', + ...(reportDB.useReportDB ? ['ArchiveSize'] : []), ...reportDB.cacheColumns.filter((c) => c !== 'Tenant'), ] @@ -71,7 +78,6 @@ const Page = () => { cardButton={ - {reportDB.controls} } diff --git a/src/pages/email/administration/quarantine/index.js b/src/pages/email/administration/quarantine/index.js index 7443eb5e714f..a52606123181 100644 --- a/src/pages/email/administration/quarantine/index.js +++ b/src/pages/email/administration/quarantine/index.js @@ -126,6 +126,7 @@ const Page = () => { data: { Identity: 'Identity', Type: '!Deny', + RecipientAddress: 'RecipientAddress', }, confirmText: 'Are you sure you want to deny this message?', icon: , diff --git a/src/pages/email/administration/tenant-allow-block-list-templates/index.js b/src/pages/email/administration/tenant-allow-block-list-templates/index.js index 85de23ce2ec4..4e945b486c4d 100644 --- a/src/pages/email/administration/tenant-allow-block-list-templates/index.js +++ b/src/pages/email/administration/tenant-allow-block-list-templates/index.js @@ -1,13 +1,26 @@ +import { useState } from 'react' import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' -import { Delete } from '@mui/icons-material' +import { Delete, Edit } from '@mui/icons-material' import CippJsonView from '../../../../components/CippFormPages/CippJSONView' import { CippTenantAllowBlockListTemplateDrawer } from '../../../../components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx' const Page = () => { const pageTitle = 'Tenant Allow/Block List Templates' + const [editDrawerVisible, setEditDrawerVisible] = useState(false) + const [editData, setEditData] = useState(null) const actions = [ + { + label: 'Edit Template', + noConfirm: true, + customFunction: (row) => { + setEditData(row) + setEditDrawerVisible(true) + }, + icon: , + color: 'primary', + }, { label: 'Delete Template', type: 'POST', @@ -36,18 +49,26 @@ const Page = () => { ] return ( - - } - /> + <> + } + /> + { + setEditDrawerVisible(visible) + if (!visible) setEditData(null) + }} + /> + ) } diff --git a/src/pages/email/reports/mailbox-cas-settings/index.js b/src/pages/email/reports/mailbox-cas-settings/index.js index 1058c83e93b2..5ae9d75cc3d8 100644 --- a/src/pages/email/reports/mailbox-cas-settings/index.js +++ b/src/pages/email/reports/mailbox-cas-settings/index.js @@ -1,29 +1,102 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { useSettings } from '../../../../hooks/use-settings.js' +import { Tune } from '@mui/icons-material' + +// CAS protocols that can be toggled, keyed by their Set-CASMailbox parameter. +// Most flags follow "*Enabled" (true = on). The inverted "*Disabled" flag +// (SMTP client auth) is the opposite: true = off. +const casProtocols = { + ECPEnabled: 'ECP (Exchange Control Panel)', + EWSEnabled: 'EWS (Exchange Web Services)', + IMAPEnabled: 'IMAP', + MAPIEnabled: 'MAPI', + OWAEnabled: 'OWA (Outlook on the Web)', + POPEnabled: 'POP', + ActiveSyncEnabled: 'ActiveSync', + SmtpClientAuthenticationDisabled: 'SMTP Client Authentication', +} const Page = () => { + const tenantFilter = useSettings().currentTenant + + // A single action lets the operator pick which protocols to enable or disable. + // Each selected protocol becomes a Set-CASMailbox flag, accounting for the inverted + // "*Disabled" parameter, and one request is sent per selected mailbox. + const actions = [ + { + label: 'Set Client Access Protocols', + type: 'POST', + icon: , + url: '/api/ExecSetCASMailbox', + fields: [ + { + type: 'radio', + name: 'enable', + label: 'Action', + options: [ + { label: 'Enable', value: true }, + { label: 'Disable', value: false }, + ], + validators: { required: 'Please choose whether to enable or disable' }, + }, + { + type: 'autoComplete', + name: 'protocols', + label: 'Protocols', + multiple: true, + creatable: false, + options: Object.entries(casProtocols).map(([value, label]) => ({ label, value })), + validators: { required: 'Please select at least one protocol' }, + }, + ], + confirmText: + 'Enable or disable the selected client access protocols for the chosen mailbox(es).', + customDataformatter: (rows, action, formData) => { + const mailboxes = Array.isArray(rows) ? rows : [rows] + const rawEnable = + typeof formData.enable === 'object' ? formData.enable?.value : formData.enable + const enable = rawEnable === true || rawEnable === 'true' + const protocolFlags = (formData.protocols ?? []).reduce((flags, selection) => { + const param = typeof selection === 'object' ? selection.value : selection + // "*Disabled" params are inverted: enabling the protocol means setting them false. + // SMTP client auth is disable-only; the API rejects an enable attempt with a message. + flags[param] = param.endsWith('Disabled') ? !enable : enable + return flags + }, {}) + + return mailboxes.map((row) => ({ + tenantFilter, + Identity: row.Guid, + DisplayName: row.DisplayName, + ...protocolFlags, + })) + }, + color: 'info', + }, + ] + return ( - ); -}; - -// No actions were specified in the original code, so no actions are added here. -// No off-canvas configuration was provided or specified in the original code. + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page diff --git a/src/pages/endpoint/MEM/assignment-filters/index.js b/src/pages/endpoint/MEM/assignment-filters/index.js index 462647494c98..bedf0ef1ada4 100644 --- a/src/pages/endpoint/MEM/assignment-filters/index.js +++ b/src/pages/endpoint/MEM/assignment-filters/index.js @@ -5,11 +5,19 @@ import Link from "next/link"; import { TrashIcon } from "@heroicons/react/24/outline"; import { Edit, Add, Book } from "@mui/icons-material"; import { Stack } from "@mui/system"; -import { useSettings } from "../../../../hooks/use-settings"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const Page = () => { const pageTitle = "Assignment Filters"; - const { currentTenant } = useSettings(); + + const reportDB = useCippReportDB({ + apiUrl: "/api/ListAssignmentFilters", + queryKey: "assignment-filters", + cacheName: "IntuneAssignmentFilters", + syncTitle: "Sync Assignment Filters Report", + allowToggle: true, + defaultCached: false, + }); const actions = [ { @@ -62,28 +70,35 @@ const Page = () => { actions: actions, }; + const simpleColumns = [ + ...reportDB.cacheColumns, + "displayName", + "description", + "platform", + "assignmentFilterManagementType", + "rule", + ]; + return ( - - - - } - apiUrl="/api/ListAssignmentFilters" - queryKey={`assignment-filters-${currentTenant}`} - actions={actions} - offCanvas={offCanvas} - simpleColumns={[ - "displayName", - "description", - "platform", - "assignmentFilterManagementType", - "rule", - ]} - /> + <> + + + {reportDB.controls} + + } + apiUrl={reportDB.resolvedApiUrl} + queryKey={reportDB.resolvedQueryKey} + actions={actions} + offCanvas={offCanvas} + simpleColumns={simpleColumns} + /> + {reportDB.syncDialog} + ); }; diff --git a/src/pages/endpoint/MEM/devices/device/tabOptions.json b/src/pages/endpoint/MEM/devices/device/tabOptions.json index e5e134f5566a..558cbe2391bb 100644 --- a/src/pages/endpoint/MEM/devices/device/tabOptions.json +++ b/src/pages/endpoint/MEM/devices/device/tabOptions.json @@ -1,6 +1,7 @@ [ { "label": "View Device", - "path": "/endpoint/MEM/devices/device" + "path": "/endpoint/MEM/devices/device", + "icon": "Computer" } ] diff --git a/src/pages/endpoint/MEM/devices/index.js b/src/pages/endpoint/MEM/devices/index.js index e8e34e9338d5..087052560120 100644 --- a/src/pages/endpoint/MEM/devices/index.js +++ b/src/pages/endpoint/MEM/devices/index.js @@ -4,7 +4,8 @@ import { CippApiDialog } from "../../../../components/CippComponents/CippApiDial import { useSettings } from "../../../../hooks/use-settings"; import { useDialog } from "../../../../hooks/use-dialog.js"; import { EyeIcon } from "@heroicons/react/24/outline"; -import { Box, Button } from "@mui/material"; +import { Button } from "@mui/material"; +import { Stack } from "@mui/system"; import { Sync, RestartAlt, @@ -412,11 +413,11 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={ - + - + } /> { const pageTitle = 'App Protection & Configuration Policies' const cardButtonPermissions = ['Endpoint.MEM.ReadWrite'] const tenant = useSettings().currentTenant + const reportDB = useCippReportDB({ + apiUrl: '/api/ListAppProtectionPolicies', + queryKey: 'ListAppProtectionPolicies', + cacheName: 'IntuneAppProtectionPolicies', + syncTitle: 'Sync App Protection Policies Report', + allowToggle: true, + defaultCached: false, + }) + const actions = useCippIntunePolicyActions(tenant, 'URLName', { templateData: { ID: 'id', @@ -31,6 +42,7 @@ const Page = () => { } const simpleColumns = [ + ...reportDB.cacheColumns, 'displayName', 'PolicyTypeName', 'PolicyAssignment', @@ -39,20 +51,27 @@ const Page = () => { ] return ( - - } - /> + <> + + + {reportDB.controls} + + } + /> + {reportDB.syncDialog} + ) } diff --git a/src/pages/endpoint/MEM/list-compliance-policies/index.js b/src/pages/endpoint/MEM/list-compliance-policies/index.js index b3394023c492..32574567a0ff 100644 --- a/src/pages/endpoint/MEM/list-compliance-policies/index.js +++ b/src/pages/endpoint/MEM/list-compliance-policies/index.js @@ -4,12 +4,23 @@ import { PermissionButton } from "../../../../utils/permissions.js"; import { CippPolicyDeployDrawer } from "../../../../components/CippComponents/CippPolicyDeployDrawer.jsx"; import { useSettings } from "../../../../hooks/use-settings.js"; import { useCippIntunePolicyActions } from "../../../../components/CippComponents/CippIntunePolicyActions.jsx"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; +import { Stack } from "@mui/system"; const Page = () => { const pageTitle = "Intune Compliance Policies"; const cardButtonPermissions = ["Endpoint.MEM.ReadWrite"]; const tenant = useSettings().currentTenant; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListCompliancePolicies", + queryKey: "ListCompliancePolicies", + cacheName: "IntuneCompliancePolicies", + syncTitle: "Sync Compliance Policies Report", + allowToggle: true, + defaultCached: false, + }); + const actions = useCippIntunePolicyActions(tenant, "deviceCompliancePolicies", { templateData: { ID: "id", @@ -29,6 +40,7 @@ const Page = () => { }; const simpleColumns = [ + ...reportDB.cacheColumns, "displayName", "PolicyTypeName", "PolicyAssignment", @@ -38,20 +50,27 @@ const Page = () => { ]; return ( - - } - /> + <> + + + {reportDB.controls} + + } + /> + {reportDB.syncDialog} + ); }; diff --git a/src/pages/endpoint/MEM/list-policies/index.js b/src/pages/endpoint/MEM/list-policies/index.js index dd241aa91016..22559f99097c 100644 --- a/src/pages/endpoint/MEM/list-policies/index.js +++ b/src/pages/endpoint/MEM/list-policies/index.js @@ -52,6 +52,7 @@ const Page = () => { 'lastModifiedDateTime', ] + return ( <> { const [codeContentChanged, setCodeContentChanged] = useState(false); const [warnOpen, setWarnOpen] = useState(false); const [currentScript, setCurrentScript] = useState(null); + const [scriptTenant, setScriptTenant] = useState(null); + + const tenantFilter = useSettings().currentTenant; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListIntuneScript", + queryKey: "ListIntuneScript", + cacheName: "IntuneScripts", + syncTitle: "Sync Intune Scripts Report", + allowToggle: true, + defaultCached: false, + }); const dispatch = useDispatch(); @@ -48,17 +60,16 @@ const Page = () => { : "powershell"; }, [currentScript?.scriptType]); - const tenantFilter = useSettings().currentTenant; const { isLoading: scriptIsLoading, isRefetching: scriptIsFetching, refetch: scriptRefetch, data, } = useQuery({ - queryKey: ["script", { scriptId }], + queryKey: ["script", { scriptId, scriptTenant }], queryFn: async () => { const response = await fetch( - `/api/EditIntuneScript?TenantFilter=${tenantFilter}&ScriptId=${scriptId}` + `/api/EditIntuneScript?TenantFilter=${scriptTenant || tenantFilter}&ScriptId=${scriptId}` ); return response.json(); }, @@ -79,6 +90,7 @@ const Page = () => { const handleScriptEdit = async (row, action) => { setScriptId(row.id); + setScriptTenant(row?.Tenant || tenantFilter); setCodeOpen(!codeOpen); }; @@ -94,6 +106,7 @@ const Page = () => { setCodeOpen(!codeOpen); setCodeContentChanged(false); setScriptId(null); + setScriptTenant(null); setCodeContent(""); } }; @@ -114,7 +127,7 @@ const Page = () => { scriptType, } = currentScript; const patchData = { - TenantFilter: tenantFilter, + TenantFilter: scriptTenant || tenantFilter, ScriptId: id, ScriptType: scriptType, IntuneScript: JSON.stringify({ @@ -197,7 +210,7 @@ const Page = () => { ], confirmText: 'Are you sure you want to assign "[displayName]" to all users?', customDataformatter: (row, action, formData) => ({ - tenantFilter: tenantFilter, + tenantFilter: tenantFilter === "AllTenants" && row?.Tenant ? row.Tenant : tenantFilter, ID: row?.id, Type: getScriptEndpoint(row?.scriptType), AssignTo: "allLicensedUsers", @@ -223,7 +236,7 @@ const Page = () => { ], confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', customDataformatter: (row, action, formData) => ({ - tenantFilter: tenantFilter, + tenantFilter: tenantFilter === "AllTenants" && row?.Tenant ? row.Tenant : tenantFilter, ID: row?.id, Type: getScriptEndpoint(row?.scriptType), AssignTo: "AllDevices", @@ -249,7 +262,7 @@ const Page = () => { ], confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', customDataformatter: (row, action, formData) => ({ - tenantFilter: tenantFilter, + tenantFilter: tenantFilter === "AllTenants" && row?.Tenant ? row.Tenant : tenantFilter, ID: row?.id, Type: getScriptEndpoint(row?.scriptType), AssignTo: "AllDevicesAndUsers", @@ -305,7 +318,7 @@ const Page = () => { customDataformatter: (row, action, formData) => { const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; return { - tenantFilter: tenantFilter, + tenantFilter: tenantFilter === "AllTenants" && row?.Tenant ? row.Tenant : tenantFilter, ID: row?.id, Type: getScriptEndpoint(row?.scriptType), GroupIds: selectedGroups.map((group) => group.value).filter(Boolean), @@ -354,6 +367,7 @@ const Page = () => { }; const simpleColumns = [ + ...reportDB.cacheColumns, "scriptType", "displayName", "ScriptAssignment", @@ -367,10 +381,12 @@ const Page = () => { <> @@ -427,6 +443,7 @@ const Page = () => { setWarnOpen(false); setCodeContent(""); setScriptId(null); + setScriptTenant(null); setCodeContentChanged(false); }} > @@ -434,9 +451,10 @@ const Page = () => { + {reportDB.syncDialog} ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/endpoint/MEM/list-templates/index.js b/src/pages/endpoint/MEM/list-templates/index.js index fcfc8aa81e5a..c1c81670d8b7 100644 --- a/src/pages/endpoint/MEM/list-templates/index.js +++ b/src/pages/endpoint/MEM/list-templates/index.js @@ -1,161 +1,251 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; -import { Edit, GitHub, LocalOffer, LocalOfferOutlined, CopyAll } from "@mui/icons-material"; -import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; -import { ApiGetCall } from "../../../../api/ApiCall"; -import { CippPolicyImportDrawer } from "../../../../components/CippComponents/CippPolicyImportDrawer.jsx"; -import { PermissionButton } from "../../../../utils/permissions.js"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline' +import { Edit, GitHub, LocalOffer, LocalOfferOutlined, CopyAll } from '@mui/icons-material' +import CippJsonView from '../../../../components/CippFormPages/CippJSONView' +import { ApiGetCall } from '../../../../api/ApiCall' +import { CippPolicyImportDrawer } from '../../../../components/CippComponents/CippPolicyImportDrawer.jsx' +import { PermissionButton } from '../../../../utils/permissions.js' +import { + Box, + Chip, + Link, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material' +import NextLink from 'next/link' const Page = () => { - const pageTitle = "Available Endpoint Manager Templates"; - const cardButtonPermissions = ["Endpoint.MEM.ReadWrite"]; + const pageTitle = 'Available Endpoint Manager Templates' + const cardButtonPermissions = ['Endpoint.MEM.ReadWrite'] const integrations = ApiGetCall({ - url: "/api/ListExtensionsConfig", - queryKey: "Integrations", + url: '/api/ListExtensionsConfig', + queryKey: 'Integrations', refetchOnMount: false, refetchOnReconnect: false, - }); + }) const actions = [ { - label: "Edit Template", + label: 'Edit Template', link: `/endpoint/MEM/list-templates/edit?id=[GUID]`, icon: , - color: "info", + color: 'info', condition: (row) => row.isSynced === false, }, { - label: "Edit Template Name and Description", - type: "POST", - url: "/api/ExecEditTemplate", + label: 'Edit Template Name and Description', + type: 'POST', + url: '/api/ExecEditTemplate', fields: [ { - type: "textField", - name: "displayName", - label: "Display Name", + type: 'textField', + name: 'displayName', + label: 'Display Name', }, { - type: "textField", - name: "description", - label: "Description", + type: 'textField', + name: 'description', + label: 'Description', }, ], - data: { GUID: "GUID", Type: "!IntuneTemplate" }, + data: { GUID: 'GUID', Type: '!IntuneTemplate' }, defaultvalues: (row) => ({ displayName: row.displayName, description: row.description, }), confirmText: - "Enter the new name and description for the template. Warning: This will disconnect the template from a template library if applied.", + 'Enter the new name and description for the template. Warning: This will disconnect the template from a template library if applied.', multiPost: false, icon: , - color: "info", + color: 'info', }, { - label: "Clone Template", - type: "POST", - url: "/api/ExecCloneTemplate", - data: { GUID: "GUID", Type: "!IntuneTemplate" }, + label: 'Clone Template', + type: 'POST', + url: '/api/ExecCloneTemplate', + data: { GUID: 'GUID', Type: '!IntuneTemplate' }, confirmText: - "Are you sure you want to clone [displayName]? Cloned template are no longer synced with a template library.", + 'Are you sure you want to clone [displayName]? Cloned template are no longer synced with a template library.', multiPost: false, icon: , - color: "info", + color: 'info', }, { - label: "Add to package", - type: "POST", - url: "/api/ExecSetPackageTag", - data: { GUID: "GUID" }, + label: 'Add to package', + type: 'POST', + url: '/api/ExecSetPackageTag', + data: { GUID: 'GUID' }, fields: [ { - type: "textField", - name: "Package", - label: "Package Name", + type: 'textField', + name: 'Package', + label: 'Package Name', required: true, validators: { - required: { value: true, message: "Package name is required" }, + required: { value: true, message: 'Package name is required' }, }, }, ], - confirmText: "Enter the package name to assign to the selected template(s).", + confirmText: 'Enter the package name to assign to the selected template(s).', multiPost: true, icon: , - color: "info", + color: 'info', }, { - label: "Remove from package", - type: "POST", - url: "/api/ExecSetPackageTag", - data: { GUID: "GUID", Remove: true }, - confirmText: "Are you sure you want to remove the selected template(s) from their package?", + label: 'Remove from package', + type: 'POST', + url: '/api/ExecSetPackageTag', + data: { GUID: 'GUID', Remove: true }, + confirmText: 'Are you sure you want to remove the selected template(s) from their package?', multiPost: true, icon: , - color: "warning", + color: 'warning', }, { - label: "Save to GitHub", - type: "POST", - url: "/api/ExecCommunityRepo", + label: 'Save to GitHub', + type: 'POST', + url: '/api/ExecCommunityRepo', icon: , data: { - Action: "UploadTemplate", - GUID: "GUID", + Action: 'UploadTemplate', + GUID: 'GUID', }, fields: [ { - label: "Repository", - name: "FullName", - type: "select", + label: 'Repository', + name: 'FullName', + type: 'select', api: { - url: "/api/ListCommunityRepos", + url: '/api/ListCommunityRepos', data: { WriteAccess: true, }, - queryKey: "CommunityRepos-Write", - dataKey: "Results", - valueField: "FullName", - labelField: "FullName", + queryKey: 'CommunityRepos-Write', + dataKey: 'Results', + valueField: 'FullName', + labelField: 'FullName', }, multiple: false, creatable: false, required: true, validators: { - required: { value: true, message: "This field is required" }, + required: { value: true, message: 'This field is required' }, }, }, { - label: "Commit Message", - placeholder: "Enter a commit message for adding this file to GitHub", - name: "Message", - type: "textField", + label: 'Commit Message', + placeholder: 'Enter a commit message for adding this file to GitHub', + name: 'Message', + type: 'textField', multiline: true, required: true, rows: 4, }, ], - confirmText: "Are you sure you want to save this template to the selected repository?", + confirmText: 'Are you sure you want to save this template to the selected repository?', condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled, }, { - label: "Delete Template", - type: "POST", - url: "/api/RemoveIntuneTemplate", - data: { ID: "GUID" }, - confirmText: "Do you want to delete the template?", + label: 'Delete Template', + type: 'POST', + url: '/api/RemoveIntuneTemplate', + data: { ID: 'GUID' }, + confirmText: 'Do you want to delete the template?', multiPost: false, icon: , - color: "danger", + color: 'danger', }, - ]; + ] const offCanvas = { - children: (row) => , - size: "lg", - }; + children: (row) => ( + + {Array.isArray(row.usage) && row.usage.length > 0 && ( + + + Used in Standards Templates + + + + + Template Name + Included In + + + + {row.usage.map((u, i) => ( + + + + {u.templateName ?? u.templateId} + + + + {u.matchType === 'package' ? ( + + } + /> + + ) : ( + + + + )} + + + ))} + +
    +
    + )} + +
    + ), + size: 'lg', + } - const simpleColumns = ["displayName", "isSynced", "package", "description", "Type"]; + const simpleColumns = ['displayName', 'isSynced', 'package', 'description', 'Type', 'usage'] + + const filterList = [ + { + filterName: 'Synced Templates', + value: [{ id: 'isSynced', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'Custom Templates', + value: [{ id: 'isSynced', value: 'No' }], + type: 'column', + }, + ] return ( <> @@ -166,6 +256,7 @@ const Page = () => { actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} + filters={filterList} queryKey="ListIntuneTemplates-table" cardButton={ { } /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/endpoint/MEM/reusable-settings/index.js b/src/pages/endpoint/MEM/reusable-settings/index.js index c301082ea1f0..75219f0d4136 100644 --- a/src/pages/endpoint/MEM/reusable-settings/index.js +++ b/src/pages/endpoint/MEM/reusable-settings/index.js @@ -4,15 +4,29 @@ import { CippTablePage } from "../../../../components/CippComponents/CippTablePa import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { useSettings } from "../../../../hooks/use-settings"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; +import { Stack } from "@mui/system"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const Page = () => { const { currentTenant } = useSettings(); const pageTitle = "Reusable Settings"; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListIntuneReusableSettings", + queryKey: "ListIntuneReusableSettings", + cacheName: "IntuneReusableSettings", + syncTitle: "Sync Reusable Settings Report", + allowToggle: true, + defaultCached: false, + }); + const isAllTenants = reportDB.isAllTenants; + const actions = [ { label: "Edit Reusable Setting", - link: `/endpoint/MEM/reusable-settings/edit?id=[id]&tenant=${currentTenant}&tenantFilter=${currentTenant}`, + link: isAllTenants + ? "/endpoint/MEM/reusable-settings/edit?id=[id]&tenant=[Tenant]&tenantFilter=[Tenant]" + : `/endpoint/MEM/reusable-settings/edit?id=[id]&tenant=${currentTenant}&tenantFilter=${currentTenant}`, }, { label: "Delete Reusable Setting", @@ -47,18 +61,32 @@ const Page = () => { size: "lg", }; + const simpleColumns = [ + ...reportDB.cacheColumns, + "displayName", + "description", + "id", + "version", + ]; + return ( - - } - apiUrl="/api/ListIntuneReusableSettings" - queryKey={`ListIntuneReusableSettings-${currentTenant}`} - actions={actions} - offCanvas={offCanvas} - simpleColumns={["displayName", "description", "id", "version"]} - /> + <> + + + {reportDB.controls} + + } + apiUrl={reportDB.resolvedApiUrl} + queryKey={reportDB.resolvedQueryKey} + actions={actions} + offCanvas={offCanvas} + simpleColumns={simpleColumns} + /> + {reportDB.syncDialog} + ); }; diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 41671638cfce..fec79a4cf7f9 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -4,9 +4,11 @@ import { CippApiDialog } from "../../../../components/CippComponents/CippApiDial import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from "@heroicons/react/24/outline"; import { LaptopMac, Sync, BookmarkAdd } from "@mui/icons-material"; import { CippApplicationDeployDrawer } from "../../../../components/CippComponents/CippApplicationDeployDrawer"; -import { Button, Box } from "@mui/material"; +import { Button } from "@mui/material"; +import { Stack } from "@mui/system"; import { useSettings } from "../../../../hooks/use-settings.js"; import { useDialog } from "../../../../hooks/use-dialog.js"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const assignmentIntentOptions = [ { label: "Required", value: "Required" }, @@ -44,9 +46,18 @@ const mapOdataToAppType = (odataType) => { const Page = () => { const pageTitle = "Applications"; - const syncDialog = useDialog(); + const vppSyncDialog = useDialog(); const tenant = useSettings().currentTenant; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListApps", + queryKey: "ListApps", + cacheName: "IntuneApplications", + syncTitle: "Sync Intune Applications Report", + allowToggle: true, + defaultCached: false, + }); + const getAssignmentFilterFields = () => [ { type: "autoComplete", @@ -291,6 +302,7 @@ const Page = () => { }; const simpleColumns = [ + ...reportDB.cacheColumns, "displayName", "AppAssignment", "AppExclude", @@ -303,22 +315,24 @@ const Page = () => { <> + - -
    + {reportDB.controls} + } /> { confirmText: `Are you sure you want to sync Apple Volume Purchase Program (VPP) tokens? This will sync all VPP tokens for ${tenant}.`, }} /> + {reportDB.syncDialog} ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/endpoint/applications/templates/index.js b/src/pages/endpoint/applications/templates/index.js index 6c4a0eb53285..b4a2a2bd1e28 100644 --- a/src/pages/endpoint/applications/templates/index.js +++ b/src/pages/endpoint/applications/templates/index.js @@ -1,122 +1,127 @@ -import { useState } from "react"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { Edit, RocketLaunch } from "@mui/icons-material"; -import { CippAppTemplateDrawer } from "../../../../components/CippComponents/CippAppTemplateDrawer"; -import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; -import { Box } from "@mui/material"; -import { ApiGetCall } from "../../../../api/ApiCall"; -import { GitHub } from "@mui/icons-material"; +import { useState } from 'react' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { TrashIcon } from '@heroicons/react/24/outline' +import { Edit, RocketLaunch } from '@mui/icons-material' +import { CippAppTemplateDrawer } from '../../../../components/CippComponents/CippAppTemplateDrawer' +import CippJsonView from '../../../../components/CippFormPages/CippJSONView' +import { Box } from '@mui/material' +import { ApiGetCall } from '../../../../api/ApiCall' +import { GitHub } from '@mui/icons-material' const Page = () => { - const pageTitle = "Application Templates"; - const [editDrawerOpen, setEditDrawerOpen] = useState(false); - const [editTemplate, setEditTemplate] = useState(null); + const pageTitle = 'Application Templates' + const [editDrawerOpen, setEditDrawerOpen] = useState(false) + const [editTemplate, setEditTemplate] = useState(null) const integrations = ApiGetCall({ - url: "/api/ListExtensionsConfig", - queryKey: "Integrations", + url: '/api/ListExtensionsConfig', + queryKey: 'Integrations', refetchOnMount: false, refetchOnReconnect: false, - }); + }) const actions = [ { - label: "Edit Template", + label: 'Edit Template', icon: , - color: "info", + color: 'info', noConfirm: true, customFunction: (row) => { - setEditTemplate({ ...row }); - setEditDrawerOpen(true); + setEditTemplate({ ...row }) + setEditDrawerOpen(true) }, }, { - label: "Save to GitHub", - type: "POST", - url: "/api/ExecCommunityRepo", + label: 'Save to GitHub', + type: 'POST', + url: '/api/ExecCommunityRepo', icon: , data: { - Action: "UploadTemplate", - GUID: "GUID", + Action: 'UploadTemplate', + GUID: 'GUID', }, fields: [ { - label: "Repository", - name: "FullName", - type: "select", + label: 'Repository', + name: 'FullName', + type: 'select', api: { - url: "/api/ListCommunityRepos", + url: '/api/ListCommunityRepos', data: { WriteAccess: true, }, - queryKey: "CommunityRepos-Write", - dataKey: "Results", - valueField: "FullName", - labelField: "FullName", + queryKey: 'CommunityRepos-Write', + dataKey: 'Results', + valueField: 'FullName', + labelField: 'FullName', }, multiple: false, creatable: false, required: true, validators: { - required: { value: true, message: "This field is required" }, + required: { value: true, message: 'This field is required' }, }, }, { - label: "Commit Message", - placeholder: "Enter a commit message for adding this file to GitHub", - name: "Message", - type: "textField", + label: 'Commit Message', + placeholder: 'Enter a commit message for adding this file to GitHub', + name: 'Message', + type: 'textField', multiline: true, required: true, rows: 4, }, ], - confirmText: "Are you sure you want to save this template to the selected repository?", + confirmText: 'Are you sure you want to save this template to the selected repository?', condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled, }, { - label: "Deploy Template", - type: "POST", - url: "/api/ExecDeployAppTemplate", + label: 'Deploy Template', + type: 'POST', + url: '/api/ExecDeployAppTemplate', icon: , - color: "info", + color: 'info', fields: [ { - type: "autoComplete", - name: "selectedTenants", - label: "Select Tenants", + type: 'autoComplete', + name: 'selectedTenants', + label: 'Select Tenants', multiple: true, creatable: false, api: { - url: "/api/ListTenants?AllTenantSelector=true", - queryKey: "ListTenants-AppTemplateDeploy", + url: '/api/ListTenants?AllTenantSelector=true', + queryKey: 'ListTenants-AppTemplateDeploy', labelField: (tenant) => `${tenant.displayName} (${tenant.defaultDomainName})`, - valueField: "defaultDomainName", + valueField: 'defaultDomainName', addedField: { - customerId: "customerId", - defaultDomainName: "defaultDomainName", + customerId: 'customerId', + defaultDomainName: 'defaultDomainName', }, }, - validators: { required: "Please select at least one tenant" }, + validators: { required: 'Please select at least one tenant' }, }, { - type: "radio", - name: "AssignTo", - label: "Override Assignment (optional)", + type: 'radio', + name: 'AssignTo', + label: 'Override Assignment (optional)', options: [ - { label: "Keep template assignment", value: "" }, - { label: "Do not assign", value: "On" }, - { label: "Assign to all users", value: "allLicensedUsers" }, - { label: "Assign to all devices", value: "AllDevices" }, - { label: "Assign to all users and devices", value: "AllDevicesAndUsers" }, - { label: "Assign to Custom Group", value: "customGroup" }, + { label: 'Keep template assignment', value: '' }, + { label: 'Do not assign', value: 'On' }, + { label: 'Assign to all users', value: 'allLicensedUsers' }, + { label: 'Assign to all devices', value: 'AllDevices' }, + { label: 'Assign to all users and devices', value: 'AllDevicesAndUsers' }, + { label: 'Assign to Custom Group', value: 'customGroup' }, ], }, { - type: "textField", - name: "customGroup", - label: "Custom Group Names (comma separated, wildcards allowed)", + type: 'textField', + name: 'customGroup', + label: 'Custom Group Names (comma separated, wildcards allowed)', + }, + { + type: 'textField', + name: 'excludeGroup', + label: 'Exclude Group Names (comma separated, wildcards allowed)', }, ], customDataformatter: (row, action, formData) => ({ @@ -125,26 +130,27 @@ const Page = () => { defaultDomainName: t.value, customerId: t.addedFields?.customerId, })), - AssignTo: formData?.AssignTo || "", - customGroup: formData?.customGroup || "", + AssignTo: formData?.AssignTo || '', + customGroup: formData?.customGroup || '', + excludeGroup: formData?.excludeGroup || '', }), confirmText: 'Deploy "[displayName]" ([appCount] apps) to the selected tenants?', }, { - label: "Delete Template", - type: "POST", - url: "/api/RemoveAppTemplate", - data: { ID: "GUID" }, + label: 'Delete Template', + type: 'POST', + url: '/api/RemoveAppTemplate', + data: { ID: 'GUID' }, confirmText: 'Delete the template "[displayName]"?', icon: , - color: "danger", + color: 'danger', }, - ]; + ] const offCanvas = { children: (row) => , - size: "lg", - }; + size: 'lg', + } return ( <> @@ -154,10 +160,10 @@ const Page = () => { apiUrl="/api/ListAppTemplates" actions={actions} offCanvas={offCanvas} - simpleColumns={["displayName", "description", "appCount", "appTypes", "appNames"]} + simpleColumns={['displayName', 'description', 'appCount', 'appTypes', 'appNames']} queryKey="ListAppTemplates" cardButton={ - + } @@ -166,13 +172,13 @@ const Page = () => { editData={editTemplate} open={editDrawerOpen} onClose={() => { - setEditDrawerOpen(false); - setEditTemplate(null); + setEditDrawerOpen(false) + setEditTemplate(null) }} /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; -export default Page; +Page.getLayout = (page) => {page} +export default Page diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js b/src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js new file mode 100644 index 000000000000..1718233d4b09 --- /dev/null +++ b/src/pages/endpoint/autopilot/enrollment-profiles/android-enterprise.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { AndroidEnterpriseEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js b/src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js new file mode 100644 index 000000000000..f03c313db2fd --- /dev/null +++ b/src/pages/endpoint/autopilot/enrollment-profiles/apple-ade.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { AppleADEEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/index.js b/src/pages/endpoint/autopilot/enrollment-profiles/index.js new file mode 100644 index 000000000000..00874da7788a --- /dev/null +++ b/src/pages/endpoint/autopilot/enrollment-profiles/index.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json b/src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json new file mode 100644 index 000000000000..83abfededa9e --- /dev/null +++ b/src/pages/endpoint/autopilot/enrollment-profiles/tabOptions.json @@ -0,0 +1,17 @@ +[ + { + "label": "Windows Autopilot", + "path": "/endpoint/autopilot/enrollment-profiles", + "icon": "Window" + }, + { + "label": "Apple ADE", + "path": "/endpoint/autopilot/enrollment-profiles/apple-ade", + "icon": "Apple" + }, + { + "label": "Android Enterprise", + "path": "/endpoint/autopilot/enrollment-profiles/android-enterprise", + "icon": "Android" + } +] diff --git a/src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js b/src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js new file mode 100644 index 000000000000..00874da7788a --- /dev/null +++ b/src/pages/endpoint/autopilot/enrollment-profiles/windows-autopilot.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { WindowsAutopilotEnrollmentProfiles } from '../../../../components/CippComponents/EnrollmentProfileTabs.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/src/pages/identity/administration/group-templates/edit.jsx b/src/pages/identity/administration/group-templates/edit.jsx index 8c83b0461005..5748cc165f72 100644 --- a/src/pages/identity/administration/group-templates/edit.jsx +++ b/src/pages/identity/administration/group-templates/edit.jsx @@ -44,6 +44,7 @@ const Page = () => { groupType: templateData.groupType, membershipRules: templateData.membershipRules, allowExternal: templateData.allowExternal, + licenses: templateData.licenses || [], tenantFilter: userSettingsDefaults.currentTenant, }); formControl.trigger(); diff --git a/src/pages/identity/administration/groups/edit.jsx b/src/pages/identity/administration/groups/edit.jsx index 4409353cb92b..a4ef9e9d5cb6 100644 --- a/src/pages/identity/administration/groups/edit.jsx +++ b/src/pages/identity/administration/groups/edit.jsx @@ -1,41 +1,43 @@ -import { useEffect, useState } from "react"; -import { Box, Button, Divider, Typography, Alert } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; -import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; -import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector"; -import { useRouter } from "next/router"; -import { ApiGetCall } from "../../../../api/ApiCall"; -import { useSettings } from "../../../../hooks/use-settings"; -import { CippFormContactSelector } from "../../../../components/CippComponents/CippFormContactSelector"; -import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; +import { useEffect, useState } from 'react' +import { Box, Button, Divider, Typography, Alert } from '@mui/material' +import { Grid } from '@mui/system' +import { useForm } from 'react-hook-form' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import CippFormPage from '../../../../components/CippFormPages/CippFormPage' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { CippFormUserSelector } from '../../../../components/CippComponents/CippFormUserSelector' +import { useRouter } from 'next/router' +import { ApiGetCall } from '../../../../api/ApiCall' +import { useSettings } from '../../../../hooks/use-settings' +import { CippFormContactSelector } from '../../../../components/CippComponents/CippFormContactSelector' +import { CippDataTable } from '../../../../components/CippTable/CippDataTable' +import { CippFormLicenseSelector } from '../../../../components/CippComponents/CippFormLicenseSelector' +import { getCippLicenseTranslation } from '../../../../utils/get-cipp-license-translation' const EditGroup = () => { - const router = useRouter(); - const { groupId, groupType } = router.query; - const [groupIdReady, setGroupIdReady] = useState(false); - const [showMembershipTable, setShowMembershipTable] = useState(false); - const [combinedData, setCombinedData] = useState([]); - const [initialValues, setInitialValues] = useState({}); - const tenantFilter = useSettings().currentTenant; + const router = useRouter() + const { groupId, groupType } = router.query + const [groupIdReady, setGroupIdReady] = useState(false) + const [showMembershipTable, setShowMembershipTable] = useState(false) + const [combinedData, setCombinedData] = useState([]) + const [initialValues, setInitialValues] = useState({}) + const tenantFilter = useSettings().currentTenant const groupInfo = ApiGetCall({ url: `/api/ListGroups?groupID=${groupId}&tenantFilter=${tenantFilter}&members=true&owners=true&groupType=${groupType}`, queryKey: `ListGroups-${groupId}`, waiting: groupIdReady, - }); + }) useEffect(() => { if (groupId) { - setGroupIdReady(true); - groupInfo.refetch(); + setGroupIdReady(true) + groupInfo.refetch() } - }, [router.query, groupId, tenantFilter]); + }, [router.query, groupId, tenantFilter]) const formControl = useForm({ - mode: "onChange", + mode: 'onChange', defaultValues: { tenantFilter: tenantFilter, AddMember: [], @@ -44,51 +46,53 @@ const EditGroup = () => { RemoveOwner: [], AddContact: [], RemoveContact: [], - visibility: "Public", + AddLicenses: [], + RemoveLicenses: [], + visibility: 'Public', }, - }); + }) useEffect(() => { if (groupInfo.isSuccess) { - const group = groupInfo.data?.groupInfo; + const group = groupInfo.data?.groupInfo if (group) { // Create combined data for the table const combinedData = [ ...(groupInfo.data?.owners?.map((o) => ({ - type: "Owner", + type: 'Owner', userPrincipalName: o.userPrincipalName, displayName: o.displayName, })) || []), ...(groupInfo.data?.members?.map((m) => ({ - type: m?.["@odata.type"] === "#microsoft.graph.orgContact" ? "Contact" : "Member", + type: m?.['@odata.type'] === '#microsoft.graph.orgContact' ? 'Contact' : 'Member', userPrincipalName: m.userPrincipalName ?? m.mail, displayName: m.displayName, })) || []), - ]; - setCombinedData(combinedData); + ] + setCombinedData(combinedData) // Create initial values object const formValues = { tenantFilter: tenantFilter, mail: group.mail, - mailNickname: group.mailNickname || "", + mailNickname: group.mailNickname || '', allowExternal: groupInfo?.data?.allowExternal, sendCopies: groupInfo?.data?.sendCopies, hideFromOutlookClients: groupInfo?.data?.hideFromOutlookClients, - visibility: group?.visibility ?? "Public", + visibility: group?.visibility ?? 'Public', displayName: group.displayName, - description: group.description || "", - membershipRules: group.membershipRule || "", + description: group.description || '', + membershipRules: group.membershipRule || '', groupId: group.id, groupType: (() => { - if (group.groupTypes?.includes("Unified")) { - return "Microsoft 365"; + if (group.groupTypes?.includes('Unified')) { + return 'Microsoft 365' } if (!group.mailEnabled && group.securityEnabled) { - return "Security"; + return 'Security' } if (group.mailEnabled && group.securityEnabled) { - return "Mail-Enabled Security"; + return 'Mail-Enabled Security' } if ( @@ -96,9 +100,9 @@ const EditGroup = () => { group.mailEnabled && !group.securityEnabled ) { - return "Distribution List"; + return 'Distribution List' } - return null; + return null })(), securityEnabled: group.securityEnabled, // Initialize empty arrays for add/remove actions @@ -108,7 +112,9 @@ const EditGroup = () => { RemoveOwner: [], AddContact: [], RemoveContact: [], - }; + AddLicenses: [], + RemoveLicenses: [], + } // Store initial values for comparison setInitialValues({ @@ -116,43 +122,43 @@ const EditGroup = () => { sendCopies: groupInfo?.data?.sendCopies, hideFromOutlookClients: groupInfo?.data?.hideFromOutlookClients, securityEnabled: group.securityEnabled, - visibility: group.visibility ?? "Public", - }); + visibility: group.visibility ?? 'Public', + }) // Reset the form with all values - formControl.reset(formValues); + formControl.reset(formValues) } } - }, [groupInfo.isSuccess, router.query, groupInfo.isFetching]); + }, [groupInfo.isSuccess, router.query, groupInfo.isFetching]) // Custom data formatter to only send changed values const customDataFormatter = (formData) => { - const cleanedData = { ...formData }; + const cleanedData = { ...formData } // Properties that should only be sent if they've changed from initial values const changeDetectionProperties = [ - "allowExternal", - "sendCopies", - "hideFromOutlookClients", - "securityEnabled", - "visibility", - ]; + 'allowExternal', + 'sendCopies', + 'hideFromOutlookClients', + 'securityEnabled', + 'visibility', + ] changeDetectionProperties.forEach((property) => { if (formData[property] === initialValues[property]) { - delete cleanedData[property]; + delete cleanedData[property] } - }); + }) - return cleanedData; - }; + return cleanedData + } return ( <> { onClick={() => setShowMembershipTable(!showMembershipTable)} sx={{ mb: 2 }} > - {showMembershipTable ? "Edit Membership" : "View members"} + {showMembershipTable ? 'Edit Membership' : 'View members'} } @@ -181,7 +187,7 @@ const EditGroup = () => { @@ -225,7 +231,7 @@ const EditGroup = () => { />
    - {groupInfo.data?.groupInfo?.groupTypes?.includes("DynamicMembership") && ( + {groupInfo.data?.groupInfo?.groupTypes?.includes('DynamicMembership') && ( { isFetching={groupInfo.isFetching} disabled={groupInfo.isFetching} addedField={{ - id: "id", - displayName: "displayName", - userPrincipalName: "userPrincipalName", + id: 'id', + displayName: 'displayName', + userPrincipalName: 'userPrincipalName', }} dataFilter={(option) => !groupInfo.data?.members?.some((m) => m.id === option.value) @@ -272,9 +278,9 @@ const EditGroup = () => { isFetching={groupInfo.isFetching} disabled={groupInfo.isFetching} addedField={{ - id: "id", - displayName: "displayName", - userPrincipalName: "userPrincipalName", + id: 'id', + displayName: 'displayName', + userPrincipalName: 'userPrincipalName', }} dataFilter={(option) => !groupInfo.data?.owners?.some((o) => o.id === option.value) @@ -289,15 +295,15 @@ const EditGroup = () => { label="Add Contacts" multiple={true} addedField={{ - id: "Guid", - displayName: "displayName", - WindowsEmailAddress: "WindowsEmailAddress", + id: 'Guid', + displayName: 'displayName', + WindowsEmailAddress: 'WindowsEmailAddress', }} isFetching={groupInfo.isFetching} disabled={groupInfo.isFetching} dataFilter={(option) => !groupInfo.data?.members - ?.filter((m) => m?.["@odata.type"] === "#microsoft.graph.orgContact") + ?.filter((m) => m?.['@odata.type'] === '#microsoft.graph.orgContact') ?.some((c) => c.id === option.value) } /> @@ -319,7 +325,7 @@ const EditGroup = () => { disabled={groupInfo.isFetching} options={ groupInfo.data?.members - ?.filter((m) => m?.["@odata.type"] !== "#microsoft.graph.orgContact") + ?.filter((m) => m?.['@odata.type'] !== '#microsoft.graph.orgContact') ?.map((m) => ({ label: `${m.displayName} (${m.userPrincipalName})`, value: m.id, @@ -369,7 +375,7 @@ const EditGroup = () => { disabled={groupInfo.isFetching} options={ groupInfo.data?.members - ?.filter((m) => m?.["@odata.type"] === "#microsoft.graph.orgContact") + ?.filter((m) => m?.['@odata.type'] === '#microsoft.graph.orgContact') ?.map((m) => ({ label: `${m.displayName} (${m.mail})`, value: m.mail, @@ -385,7 +391,7 @@ const EditGroup = () => { Group Settings - {groupType === "Microsoft 365" && ( + {groupType === 'Microsoft 365' && ( { isFetching={groupInfo.isFetching} disabled={groupInfo.isFetching} options={[ - { label: "Public", value: "Public" }, - { label: "Private", value: "Private" }, + { label: 'Public', value: 'Public' }, + { label: 'Private', value: 'Private' }, ]} /> )} - {(groupType === "Microsoft 365" || groupType === "Distribution List") && ( + {(groupType === 'Microsoft 365' || groupType === 'Distribution List') && ( { )} - {groupType === "Microsoft 365" && ( + {groupType === 'Microsoft 365' && ( { )} - {groupType === "Microsoft 365" && ( + {groupType === 'Microsoft 365' && ( { /> )} - {groupType === "Microsoft 365" && ( + {groupType === 'Microsoft 365' && ( { /> )} + + {groupType === 'Security' && !groupInfo.data?.groupInfo?.onPremisesSyncEnabled && ( + <> + + Licenses + + Licenses assigned to this group are automatically applied to all members. + Changes can take 2-5 minutes to propagate. + + + + {groupInfo.data?.groupInfo?.assignedLicenses?.length > 0 && ( + + + Currently assigned licenses: + + {groupInfo.data.groupInfo.assignedLicenses.map((lic) => ( + + - {getCippLicenseTranslation([lic])} + + ))} + + )} + + + + + + + ({ + label: getCippLicenseTranslation([lic]), + value: lic.skuId, + })) || [] + } + sortOptions={true} + /> + + + )}
    )} - ); -}; + ) +} -EditGroup.getLayout = (page) => {page}; +EditGroup.getLayout = (page) => {page} -export default EditGroup; +export default EditGroup diff --git a/src/pages/identity/administration/groups/group/tabOptions.json b/src/pages/identity/administration/groups/group/tabOptions.json index f092f4cb37d6..18fa62ad879f 100644 --- a/src/pages/identity/administration/groups/group/tabOptions.json +++ b/src/pages/identity/administration/groups/group/tabOptions.json @@ -1,6 +1,7 @@ [ { "label": "View Group", - "path": "/identity/administration/groups/group" + "path": "/identity/administration/groups/group", + "icon": "Group" } ] diff --git a/src/pages/identity/administration/jit-admin/add.jsx b/src/pages/identity/administration/jit-admin/add.jsx index d99a1b6518c1..478f7ca51bfc 100644 --- a/src/pages/identity/administration/jit-admin/add.jsx +++ b/src/pages/identity/administration/jit-admin/add.jsx @@ -1,112 +1,111 @@ -import { Box, Divider } from "@mui/material"; -import { Grid } from "@mui/system"; -import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippFormTenantSelector } from "../../../../components/CippComponents/CippFormTenantSelector"; -import { useForm, useWatch } from "react-hook-form"; -import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; -import { CippFormCondition } from "../../../../components/CippComponents/CippFormCondition"; -import gdaproles from "../../../../data/GDAPRoles.json"; -import countryList from "../../../../data/countryList.json"; -import { CippFormDomainSelector } from "../../../../components/CippComponents/CippFormDomainSelector"; -import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector"; -import { CippFormGroupSelector } from "../../../../components/CippComponents/CippFormGroupSelector"; -import { ApiGetCall } from "../../../../api/ApiCall"; -import { useEffect, useState } from "react"; +import { Box, Divider } from '@mui/material' +import { Grid } from '@mui/system' +import CippFormPage from '../../../../components/CippFormPages/CippFormPage' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippFormTenantSelector } from '../../../../components/CippComponents/CippFormTenantSelector' +import { useForm, useWatch } from 'react-hook-form' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { CippFormCondition } from '../../../../components/CippComponents/CippFormCondition' +import gdaproles from '../../../../data/GDAPRoles.json' +import countryList from '../../../../data/countryList.json' +import { CippFormDomainSelector } from '../../../../components/CippComponents/CippFormDomainSelector' +import { CippFormUserSelector } from '../../../../components/CippComponents/CippFormUserSelector' +import { CippFormGroupSelector } from '../../../../components/CippComponents/CippFormGroupSelector' +import { ApiGetCall } from '../../../../api/ApiCall' +import { useEffect, useState } from 'react' const Page = () => { - const formControl = useForm({ mode: "onChange" }); - const selectedTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); - const [selectedTemplate, setSelectedTemplate] = useState(null); + const formControl = useForm({ mode: 'onChange' }) + const selectedTenant = useWatch({ control: formControl.control, name: 'tenantFilter' }) + const [selectedTemplate, setSelectedTemplate] = useState(null) const jitAdminTemplates = ApiGetCall({ url: selectedTenant ? `/api/ListJITAdminTemplates?TenantFilter=${selectedTenant.value}` : undefined, - queryKey: selectedTenant ? `JITAdminTemplates-${selectedTenant.value}` : "JITAdminTemplates", + queryKey: selectedTenant ? `JITAdminTemplates-${selectedTenant.value}` : 'JITAdminTemplates', refetchOnMount: false, refetchOnReconnect: false, waiting: !!selectedTenant, - }); + }) - const watcher = useWatch({ control: formControl.control }); - const useTAP = useWatch({ control: formControl.control, name: "UseTAP" }); - const startDate = useWatch({ control: formControl.control, name: "startDate" }); - const endDate = useWatch({ control: formControl.control, name: "endDate" }); + const watcher = useWatch({ control: formControl.control }) + const useTAP = useWatch({ control: formControl.control, name: 'UseTAP' }) + const startDate = useWatch({ control: formControl.control, name: 'startDate' }) + const endDate = useWatch({ control: formControl.control, name: 'endDate' }) const tapLifetimeInMinutes = useWatch({ control: formControl.control, - name: "tapLifetimeInMinutes", - }); + name: 'tapLifetimeInMinutes', + }) const tapPolicy = ApiGetCall({ - url: selectedTenant - ? `/api/ListGraphRequest` - : undefined, + url: selectedTenant ? `/api/ListGraphRequest` : undefined, data: { - Endpoint: "policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass", + Endpoint: + 'policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass', tenantFilter: selectedTenant?.value, }, - queryKey: selectedTenant ? `TAPPolicy-${selectedTenant.value}` : "TAPPolicy", + queryKey: selectedTenant ? `TAPPolicy-${selectedTenant.value}` : 'TAPPolicy', waiting: !!selectedTenant, - }); - const tapEnabled = tapPolicy.isSuccess && tapPolicy.data?.Results?.[0]?.state === "enabled"; - const useRoles = useWatch({ control: formControl.control, name: "useRoles" }); - const useGroups = useWatch({ control: formControl.control, name: "useGroups" }); + }) + const tapEnabled = tapPolicy.isSuccess && tapPolicy.data?.Results?.[0]?.state === 'enabled' + const useRoles = useWatch({ control: formControl.control, name: 'useRoles' }) + const useGroups = useWatch({ control: formControl.control, name: 'useGroups' }) useEffect(() => { if (!useTAP || !startDate || !endDate) { - formControl.setValue("tapLifetimeInMinutes", null); - return; + formControl.setValue('tapLifetimeInMinutes', null) + return } - const requestedMinutes = Math.max(1, Math.round((endDate - startDate) / 60)); - const tapPolicyConfig = tapPolicy.data?.Results?.[0]; - const policyMax = tapPolicyConfig?.maximumLifetimeInMinutes ?? 1440; - const policyMin = Math.min(tapPolicyConfig?.minimumLifetimeInMinutes ?? 1, policyMax); + const requestedMinutes = Math.max(1, Math.round((endDate - startDate) / 60)) + const tapPolicyConfig = tapPolicy.data?.Results?.[0] + const policyMax = tapPolicyConfig?.maximumLifetimeInMinutes ?? 1440 + const policyMin = Math.min(tapPolicyConfig?.minimumLifetimeInMinutes ?? 1, policyMax) formControl.setValue( - "tapLifetimeInMinutes", + 'tapLifetimeInMinutes', Math.min(Math.max(requestedMinutes, policyMin), policyMax) - ); - }, [useTAP, startDate, endDate, tapPolicy.data, formControl]); + ) + }, [useTAP, startDate, endDate, tapPolicy.data, formControl]) // Clear fields when switches are toggled off useEffect(() => { if (!useRoles) { - formControl.setValue("adminRoles", []); + formControl.setValue('adminRoles', []) } - }, [useRoles]); + }, [useRoles]) useEffect(() => { if (!useGroups) { - formControl.setValue("groupMemberships", []); + formControl.setValue('groupMemberships', []) } - }, [useGroups]); + }, [useGroups]) // Reset expiration action when switches change useEffect(() => { - const currentAction = formControl.getValues("expireAction"); - if (!currentAction?.value) return; + const currentAction = formControl.getValues('expireAction') + if (!currentAction?.value) return - if (!useRoles && currentAction.value === "RemoveRoles") { - formControl.setValue("expireAction", null); - } else if (!useGroups && currentAction.value === "RemoveGroups") { - formControl.setValue("expireAction", null); - } else if ((!useRoles || !useGroups) && currentAction.value === "RemoveRolesAndGroups") { - formControl.setValue("expireAction", null); - } else if (useRoles && useGroups && currentAction.value === "RemoveRoles") { - formControl.setValue("expireAction", null); - } else if (useRoles && useGroups && currentAction.value === "RemoveGroups") { - formControl.setValue("expireAction", null); + if (!useRoles && currentAction.value === 'RemoveRoles') { + formControl.setValue('expireAction', null) + } else if (!useGroups && currentAction.value === 'RemoveGroups') { + formControl.setValue('expireAction', null) + } else if ((!useRoles || !useGroups) && currentAction.value === 'RemoveRolesAndGroups') { + formControl.setValue('expireAction', null) + } else if (useRoles && useGroups && currentAction.value === 'RemoveRoles') { + formControl.setValue('expireAction', null) + } else if (useRoles && useGroups && currentAction.value === 'RemoveGroups') { + formControl.setValue('expireAction', null) } - }, [useRoles, useGroups]); + }, [useRoles, useGroups]) // Simple duration parser for basic ISO 8601 durations const parseDuration = (duration) => { - if (!duration) return null; + if (!duration) return null const matches = duration.match( /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/ - ); - if (!matches) return null; + ) + if (!matches) return null return { years: parseInt(matches[1] || 0), months: parseInt(matches[2] || 0), @@ -115,159 +114,159 @@ const Page = () => { hours: parseInt(matches[5] || 0), minutes: parseInt(matches[6] || 0), seconds: parseInt(matches[7] || 0), - }; - }; + } + } const addDurationToDate = (date, duration) => { - if (!date || !duration) return null; - const parsed = parseDuration(duration); - if (!parsed) return null; + if (!date || !duration) return null + const parsed = parseDuration(duration) + if (!parsed) return null - const result = new Date(date); - result.setFullYear(result.getFullYear() + parsed.years); - result.setMonth(result.getMonth() + parsed.months); - result.setDate(result.getDate() + parsed.weeks * 7); - result.setDate(result.getDate() + parsed.days); - result.setHours(result.getHours() + parsed.hours); - result.setMinutes(result.getMinutes() + parsed.minutes); - result.setSeconds(result.getSeconds() + parsed.seconds); - return result; - }; + const result = new Date(date) + result.setFullYear(result.getFullYear() + parsed.years) + result.setMonth(result.getMonth() + parsed.months) + result.setDate(result.getDate() + parsed.weeks * 7) + result.setDate(result.getDate() + parsed.days) + result.setHours(result.getHours() + parsed.hours) + result.setMinutes(result.getMinutes() + parsed.minutes) + result.setSeconds(result.getSeconds() + parsed.seconds) + return result + } // Auto-select default template for tenant // Priority: tenant-specific default > AllTenants default useEffect(() => { if (jitAdminTemplates.isSuccess && !watcher.jitAdminTemplate) { - const templates = jitAdminTemplates.data || []; + const templates = jitAdminTemplates.data || [] // First, try to find a tenant-specific default template let defaultTemplate = templates.find( (template) => template.defaultForTenant === true && - template.tenantFilter !== "AllTenants" && + template.tenantFilter !== 'AllTenants' && template.tenantFilter === selectedTenant?.value - ); + ) // If not found, fall back to AllTenants default template if (!defaultTemplate) { defaultTemplate = templates.find( - (template) => template.defaultForTenant === true && template.tenantFilter === "AllTenants" - ); + (template) => template.defaultForTenant === true && template.tenantFilter === 'AllTenants' + ) } if (defaultTemplate) { - formControl.setValue("jitAdminTemplate", { + formControl.setValue('jitAdminTemplate', { label: defaultTemplate.templateName, value: defaultTemplate.GUID, addedFields: defaultTemplate, - }); - setSelectedTemplate(defaultTemplate); + }) + setSelectedTemplate(defaultTemplate) } } - }, [jitAdminTemplates.isSuccess, selectedTenant]); + }, [jitAdminTemplates.isSuccess, selectedTenant]) // Only set template-driven fields when the template actually changes - const [lastTemplate, setLastTemplate] = useState(null); + const [lastTemplate, setLastTemplate] = useState(null) useEffect(() => { - const template = watcher.jitAdminTemplate?.addedFields; - if (!template || template.GUID === lastTemplate) return; - setSelectedTemplate(template); - setLastTemplate(template.GUID); + const template = watcher.jitAdminTemplate?.addedFields + if (!template || template.GUID === lastTemplate) return + setSelectedTemplate(template) + setLastTemplate(template.GUID) // Helpers const roundDown15 = (date) => { - const d = new Date(date); - d.setMilliseconds(0); - d.setSeconds(0); - d.setMinutes(Math.floor(d.getMinutes() / 15) * 15); - return d; - }; + const d = new Date(date) + d.setMilliseconds(0) + d.setSeconds(0) + d.setMinutes(Math.floor(d.getMinutes() / 15) * 15) + return d + } const roundUp15 = (date) => { - const d = new Date(date); - d.setMilliseconds(0); - d.setSeconds(0); - let min = d.getMinutes(); - d.setMinutes(min % 15 === 0 ? min : Math.ceil(min / 15) * 15); + const d = new Date(date) + d.setMilliseconds(0) + d.setSeconds(0) + let min = d.getMinutes() + d.setMinutes(min % 15 === 0 ? min : Math.ceil(min / 15) * 15) if (d.getMinutes() === 60) { - d.setHours(d.getHours() + 1); - d.setMinutes(0); + d.setHours(d.getHours() + 1) + d.setMinutes(0) } - return d; - }; + return d + } // Set all template-driven fields - formControl.setValue("useRoles", template.defaultUseRoles ?? true, { shouldDirty: true }); - formControl.setValue("useGroups", template.defaultUseGroups ?? false, { shouldDirty: true }); - formControl.setValue("adminRoles", template.defaultRoles || [], { shouldDirty: true }); - formControl.setValue("groupMemberships", template.defaultGroups || [], { shouldDirty: true }); - formControl.setValue("expireAction", template.defaultExpireAction || null, { + formControl.setValue('useRoles', template.defaultUseRoles ?? true, { shouldDirty: true }) + formControl.setValue('useGroups', template.defaultUseGroups ?? false, { shouldDirty: true }) + formControl.setValue('adminRoles', template.defaultRoles || [], { shouldDirty: true }) + formControl.setValue('groupMemberships', template.defaultGroups || [], { shouldDirty: true }) + formControl.setValue('expireAction', template.defaultExpireAction || null, { shouldDirty: true, - }); - formControl.setValue("postExecution", template.defaultNotificationActions || [], { + }) + formControl.setValue('postExecution', template.defaultNotificationActions || [], { shouldDirty: true, - }); - formControl.setValue("UseTAP", template.generateTAPByDefault ?? false, { shouldDirty: true }); - formControl.setValue("reason", template.reasonTemplate || "", { shouldDirty: true }); + }) + formControl.setValue('UseTAP', template.generateTAPByDefault ?? false, { shouldDirty: true }) + formControl.setValue('reason', template.reasonTemplate || '', { shouldDirty: true }) // User action and user details if (template.defaultUserAction) { - formControl.setValue("userAction", template.defaultUserAction, { shouldDirty: true }); + formControl.setValue('userAction', template.defaultUserAction, { shouldDirty: true }) } if (template.defaultFirstName) { - formControl.setValue("firstName", template.defaultFirstName, { shouldDirty: true }); + formControl.setValue('firstName', template.defaultFirstName, { shouldDirty: true }) } if (template.defaultLastName) { - formControl.setValue("lastName", template.defaultLastName, { shouldDirty: true }); + formControl.setValue('lastName', template.defaultLastName, { shouldDirty: true }) } if (template.defaultUserName) { - formControl.setValue("userName", template.defaultUserName, { shouldDirty: true }); + formControl.setValue('userName', template.defaultUserName, { shouldDirty: true }) } if (template.defaultDomain) { - formControl.setValue("domain", template.defaultDomain, { shouldDirty: true }); + formControl.setValue('domain', template.defaultDomain, { shouldDirty: true }) } if (template.defaultExistingUser) { - formControl.setValue("existingUser", template.defaultExistingUser, { shouldDirty: true }); + formControl.setValue('existingUser', template.defaultExistingUser, { shouldDirty: true }) } if (template.defaultUsageLocation) { - formControl.setValue("usageLocation", template.defaultUsageLocation, { shouldDirty: true }); + formControl.setValue('usageLocation', template.defaultUsageLocation, { shouldDirty: true }) } // Dates if (template.defaultDuration) { const duration = - typeof template.defaultDuration === "object" && template.defaultDuration !== null + typeof template.defaultDuration === 'object' && template.defaultDuration !== null ? template.defaultDuration.value - : template.defaultDuration; - const start = roundDown15(new Date()); - const unixStart = Math.floor(start.getTime() / 1000); - formControl.setValue("startDate", unixStart, { shouldDirty: true }); - const end = roundUp15(addDurationToDate(start, duration)); - const unixEnd = Math.floor(end.getTime() / 1000); - formControl.setValue("endDate", unixEnd, { shouldDirty: true }); + : template.defaultDuration + const start = roundDown15(new Date()) + const unixStart = Math.floor(start.getTime() / 1000) + formControl.setValue('startDate', unixStart, { shouldDirty: true }) + const end = roundUp15(addDurationToDate(start, duration)) + const unixEnd = Math.floor(end.getTime() / 1000) + formControl.setValue('endDate', unixEnd, { shouldDirty: true }) } - }, [watcher.jitAdminTemplate, lastTemplate]); + }, [watcher.jitAdminTemplate, lastTemplate]) // Recalculate end date when start date changes and template has default duration useEffect(() => { if (watcher.startDate && selectedTemplate?.defaultDuration) { const durationValue = - typeof selectedTemplate.defaultDuration === "object" && + typeof selectedTemplate.defaultDuration === 'object' && selectedTemplate.defaultDuration !== null ? selectedTemplate.defaultDuration.value - : selectedTemplate.defaultDuration; - const startDateDate = new Date(watcher.startDate * 1000); - const endDateObj = addDurationToDate(startDateDate, durationValue); + : selectedTemplate.defaultDuration + const startDateDate = new Date(watcher.startDate * 1000) + const endDateObj = addDurationToDate(startDateDate, durationValue) if (endDateObj) { - const unixEnd = Math.floor(endDateObj.getTime() / 1000); - formControl.setValue("endDate", unixEnd); + const unixEnd = Math.floor(endDateObj.getTime() / 1000) + formControl.setValue('endDate', unixEnd) } } - }, [watcher.startDate]); + }, [watcher.startDate]) return ( <> { @@ -313,11 +312,11 @@ const Page = () => { row formControl={formControl} options={[ - { label: "New User", value: "create" }, - { label: "Existing User", value: "select" }, + { label: 'New User', value: 'create' }, + { label: 'Existing User', value: 'select' }, ]} required={true} - validators={{ required: "You must select an option" }} + validators={{ required: 'You must select an option' }} /> @@ -335,7 +334,7 @@ const Page = () => { name="firstName" formControl={formControl} required={true} - validators={{ required: "First Name is required" }} + validators={{ required: 'First Name is required' }} /> @@ -346,7 +345,7 @@ const Page = () => { name="lastName" formControl={formControl} required={true} - validators={{ required: "Last Name is required" }} + validators={{ required: 'Last Name is required' }} /> @@ -357,7 +356,7 @@ const Page = () => { name="userName" formControl={formControl} required={true} - validators={{ required: "Username is required" }} + validators={{ required: 'Username is required' }} /> @@ -366,7 +365,7 @@ const Page = () => { name="domain" label="Domain Name" required={true} - validators={{ required: "Domain is required" }} + validators={{ required: 'Domain is required' }} /> @@ -380,6 +379,7 @@ const Page = () => { value: Code, }))} formControl={formControl} + creatable={false} /> @@ -400,7 +400,7 @@ const Page = () => { name="existingUser" label="User" required={true} - validators={{ required: "User is required" }} + validators={{ required: 'User is required' }} /> @@ -413,7 +413,7 @@ const Page = () => { name="startDate" formControl={formControl} required={true} - validators={{ required: "Start date is required" }} + validators={{ required: 'Start date is required' }} /> @@ -425,13 +425,13 @@ const Page = () => { formControl={formControl} required={true} validators={{ - required: "End date is required", + required: 'End date is required', validate: (value) => { - const startDate = formControl.getValues("startDate"); + const startDate = formControl.getValues('startDate') if (value && startDate && new Date(value) < new Date(startDate)) { - return "End date must be after start date"; + return 'End date must be after start date' } - return true; + return true }, }} /> @@ -452,7 +452,7 @@ const Page = () => { {!useRoles && !useGroups && ( - + Please select at least "Admin Roles" or "Group Membership" @@ -462,7 +462,7 @@ const Page = () => { field="useRoles" compareType="is" compareValue={true} - > + > { formControl={formControl} required={true} validators={{ - required: "At least one role is required", + required: 'At least one role is required', validate: (options) => { if (!options?.length) { - return "At least one role is required"; + return 'At least one role is required' } - return true; + return true }, }} + creatable={false} /> @@ -498,12 +499,12 @@ const Page = () => { multiple={true} required={true} validators={{ - required: "At least one group is required", + required: 'At least one group is required', validate: (options) => { if (!options?.length) { - return "At least one group is required"; + return 'At least one group is required' } - return true; + return true }, }} /> @@ -519,7 +520,7 @@ const Page = () => { rows={3} formControl={formControl} required={true} - validators={{ required: "A reason is required" }} + validators={{ required: 'A reason is required' }} /> @@ -535,12 +536,12 @@ const Page = () => { formControl={formControl} /> {useTAP && tapPolicy.isSuccess && !tapEnabled && ( - + TAP is not enabled in this tenant. TAP generation will fail. )} {useTAP && tapLifetimeInMinutes && ( - + TAP will be valid for {tapLifetimeInMinutes} minutes. )} @@ -556,20 +557,20 @@ const Page = () => { required={true} options={(() => { const opts = [ - { label: "Delete User", value: "DeleteUser" }, - { label: "Disable User", value: "DisableUser" }, - ]; + { label: 'Delete User', value: 'DeleteUser' }, + { label: 'Disable User', value: 'DisableUser' }, + ] if (useRoles && useGroups) { - opts.push({ label: "Remove Roles and Groups", value: "RemoveRolesAndGroups" }); + opts.push({ label: 'Remove Roles and Groups', value: 'RemoveRolesAndGroups' }) } else if (useRoles) { - opts.push({ label: "Remove Roles", value: "RemoveRoles" }); + opts.push({ label: 'Remove Roles', value: 'RemoveRoles' }) } else if (useGroups) { - opts.push({ label: "Remove Groups", value: "RemoveGroups" }); + opts.push({ label: 'Remove Groups', value: 'RemoveGroups' }) } - return opts; + return opts })()} formControl={formControl} - validators={{ required: "Expiration action is required" }} + validators={{ required: 'Expiration action is required' }} /> @@ -581,9 +582,9 @@ const Page = () => { multiple={true} creatable={false} options={[ - { label: "Webhook", value: "Webhook" }, - { label: "Email", value: "email" }, - { label: "PSA", value: "PSA" }, + { label: 'Webhook', value: 'Webhook' }, + { label: 'Email', value: 'email' }, + { label: 'PSA', value: 'PSA' }, ]} formControl={formControl} /> @@ -592,9 +593,9 @@ const Page = () => { - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page diff --git a/src/pages/identity/administration/roles/index.js b/src/pages/identity/administration/roles/index.js index f09fb8a01388..27b95f7ed9c9 100644 --- a/src/pages/identity/administration/roles/index.js +++ b/src/pages/identity/administration/roles/index.js @@ -1,24 +1,71 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { usePermissions } from '../../../../hooks/use-permissions' +import { PersonRemove } from '@mui/icons-material' + +const RemoveRoleMembersForm = ({ formHook, row }) => { + const memberOptions = (row?.Members ?? []).map((member) => ({ + label: member.userPrincipalName + ? `${member.displayName} (${member.userPrincipalName})` + : member.displayName, + value: member.id, + addedFields: { + displayName: member.displayName, + userPrincipalName: member.userPrincipalName, + }, + })) + + return ( + + ) +} const Page = () => { - const pageTitle = "Roles"; + const pageTitle = 'Roles' + const { checkPermissions } = usePermissions() + const canWriteRole = checkPermissions(['Identity.Role.ReadWrite']) - const actions = []; + const actions = [ + { + label: 'Remove Members', + type: 'POST', + icon: , + url: '/api/ExecRemoveAdminRole', + children: ({ formHook, row }) => , + data: { + RoleId: 'Id', + RoleName: 'DisplayName', + }, + confirmText: 'Select the members to remove from [DisplayName].', + allowResubmit: true, + hideBulk: true, + condition: (row) => canWriteRole && (row?.Members ?? []).length > 0, + }, + ] const offCanvas = { extendedInfoFields: [ - "DisplayName", // Role Group Name - "Members", // Member Names + 'DisplayName', // Role Group Name + 'Members', // Member Names ], actions: actions, - }; + } const columns = [ - "DisplayName", // Role Name - "Description", // Description - "Members", // Members - ]; + 'DisplayName', // Role Name + 'Description', // Description + 'Members', // Members + ] return ( { offCanvas={offCanvas} simpleColumns={columns} /> - ); -}; + ) +} -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page} -export default Page; +export default Page diff --git a/src/pages/identity/administration/users/patch-wizard.jsx b/src/pages/identity/administration/users/patch-wizard.jsx index 1168f12f593e..67e1d08041a9 100644 --- a/src/pages/identity/administration/users/patch-wizard.jsx +++ b/src/pages/identity/administration/users/patch-wizard.jsx @@ -21,11 +21,17 @@ import { CippWizardStepButtons } from '../../../../components/CippWizard/CippWiz import { ApiPostCall, ApiGetCall } from '../../../../api/ApiCall' import { CippApiResults } from '../../../../components/CippComponents/CippApiResults' import { CippDataTable } from '../../../../components/CippTable/CippDataTable' +import { CippFormDomainSelector } from '../../../../components/CippComponents/CippFormDomainSelector' import { CippFormUserSelector } from '../../../../components/CippComponents/CippFormUserSelector' import { Delete } from '@mui/icons-material' // User properties that can be patched const PATCHABLE_PROPERTIES = [ + { + property: 'businessPhones', + label: 'Business Phone', + type: 'string', + }, { property: 'city', label: 'City', @@ -51,6 +57,11 @@ const PATCHABLE_PROPERTIES = [ label: 'Employee Type', type: 'string', }, + { + property: 'faxNumber', + label: 'Fax Number', + type: 'string', + }, { property: 'jobTitle', label: 'Job Title', @@ -61,6 +72,11 @@ const PATCHABLE_PROPERTIES = [ label: 'Manager', type: 'userSelector', }, + { + property: 'mobilePhone', + label: 'Mobile Phone', + type: 'string', + }, { property: 'officeLocation', label: 'Office Location', @@ -106,6 +122,11 @@ const PATCHABLE_PROPERTIES = [ label: 'Usage Location', type: 'string', }, + { + property: 'userPrincipalName', + label: 'UPN Domain Suffix', + type: 'domainPicker', + }, ] // Step 1: Display users to be updated @@ -195,12 +216,15 @@ const PropertySelectionStep = (props) => { const tenantDomains = [...new Set(users?.map((user) => user.Tenant || user.tenantFilter).filter(Boolean))] || [] const firstTenantDomain = tenantDomains[0] + const isSingleTenant = tenantDomains.length <= 1 const hasManagerSelected = selectedProperties.includes('manager') const hasSponsorSelected = selectedProperties.includes('sponsor') const hasRelationshipSelected = hasManagerSelected || hasSponsorSelected + const hasUPNSelected = selectedProperties.includes('userPrincipalName') + const hasTenantScopedSelectorSelected = hasRelationshipSelected || hasUPNSelected useEffect(() => { - if (!hasRelationshipSelected || !firstTenantDomain) { + if (!hasTenantScopedSelectorSelected || !firstTenantDomain) { return } @@ -208,7 +232,7 @@ const PropertySelectionStep = (props) => { if (currentTenantFilter?.value !== firstTenantDomain) { formControl.setValue('tenantFilter', { value: firstTenantDomain }) } - }, [firstTenantDomain, formControl, hasRelationshipSelected]) + }, [firstTenantDomain, formControl, hasTenantScopedSelectorSelected]) // Fetch custom data mappings for all tenants const customDataMappings = ApiGetCall({ @@ -242,8 +266,13 @@ const PropertySelectionStep = (props) => { // Combine standard properties with custom data properties const allProperties = useMemo(() => { - return [...PATCHABLE_PROPERTIES, ...customDataProperties] - }, [customDataProperties]) + return [ + ...PATCHABLE_PROPERTIES.filter( + (property) => isSingleTenant || property.property !== 'userPrincipalName' + ), + ...customDataProperties, + ] + }, [customDataProperties, isSingleTenant]) // Register form fields formControl.register('selectedProperties', { required: true }) @@ -290,6 +319,24 @@ const PropertySelectionStep = (props) => { ) } + if (property?.type === 'domainPicker') { + return ( + + + Changes the domain after @ only. Users will be logged out and must sign in with the new + UPN. + + + + ) + } + // Default to text input for string types with consistent styling return ( { Loading custom data mappings... )} + {!isSingleTenant && ( + + UPN domain suffix changes are only available when all selected users are from the same + tenant. + + )} { return } + if (propName === 'businessPhones') { + userData[propName] = [propertyValue] + return + } + + if (propName === 'userPrincipalName') { + const selectedDomain = propertyValue?.value || propertyValue?.label || propertyValue + const currentUPN = user.userPrincipalName || '' + const upnPrefix = currentUPN.includes('@') ? currentUPN.split('@')[0] : currentUPN + + if (selectedDomain && upnPrefix) { + userData[propName] = `${upnPrefix}@${selectedDomain}` + } + + return + } + // Handle dot notation properties (e.g., "extension_abc123.customField") if (propName.includes('.')) { const parts = propName.split('.') @@ -581,6 +651,9 @@ const ConfirmationStep = (props) => { if (propName === 'manager' || propName === 'sponsor') { displayValue = value?.label || value?.value || 'Not set' + } else if (propName === 'userPrincipalName') { + const selectedDomain = value?.label || value?.value || value + displayValue = selectedDomain ? `Change domain to: ${selectedDomain}` : 'Not set' } else if (property?.type === 'boolean') { displayValue = value ? 'Yes' : 'No' } diff --git a/src/pages/identity/administration/users/user/devices.jsx b/src/pages/identity/administration/users/user/devices.jsx deleted file mode 100644 index 9fcb9086bdbc..000000000000 --- a/src/pages/identity/administration/users/user/devices.jsx +++ /dev/null @@ -1,380 +0,0 @@ -import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; -import { useSettings } from "../../../../../hooks/use-settings"; -import { useRouter } from "next/router"; -import { ApiGetCall } from "../../../../../api/ApiCall"; -import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton"; -import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon"; -import { Check, Mail, Fingerprint } from "@mui/icons-material"; -import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; -import tabOptions from "./tabOptions"; -import ReactTimeAgo from "react-time-ago"; -import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard"; -import { Box, Stack } from "@mui/system"; -import { Grid } from "@mui/system"; -import { CippUserInfoCard } from "../../../../../components/CippCards/CippUserInfoCard"; -import { Typography } from "@mui/material"; -import { CippBannerListCard } from "../../../../../components/CippCards/CippBannerListCard"; - -const Page = () => { - const userSettingsDefaults = useSettings(); - const router = useRouter(); - const { userId } = router.query; - - const userRequest = ApiGetCall({ - url: `/api/ListUsers?UserId=${userId}&tenantFilter=${userSettingsDefaults.currentTenant}`, - queryKey: `ListUsers-${userId}`, - }); - - const MFARequest = ApiGetCall({ - url: "/api/ListGraphRequest", - data: { - Endpoint: `/users/${userId}/authentication/methods`, - tenantFilter: userSettingsDefaults.currentTenant, - noPagination: true, - $top: 99, - }, - queryKey: `MFA-${userId}`, - }); - - const signInLogs = ApiGetCall({ - url: `/api/ListUserSigninLogs?UserId=${userId}&tenantFilter=${userSettingsDefaults.currentTenant}&top=1`, - queryKey: `ListSignIns-${userId}`, - }); - - // Set the title and subtitle for the layout - const title = userRequest.isSuccess ? userRequest.data?.[0]?.displayName : "Loading..."; - - const subtitle = userRequest.isSuccess - ? [ - { - icon: , - text: , - }, - { - icon: , - text: , - }, - { - icon: , - text: ( - <> - Created: {" "} - - ), - }, - ] - : []; - - const data = userRequest.data?.[0]; - - // Prepare the sign-in log item - let signInLogItem = null; - let conditionalAccessPoliciesItems = []; - let mfaDevicesItems = []; - - if (signInLogs.isSuccess && signInLogs.data && signInLogs.data.length > 0) { - const signInData = signInLogs.data[0]; - - signInLogItem = { - id: 1, - cardLabelBox: { - cardLabelBoxHeader: new Date(signInData.createdDateTime).getDate().toString(), - cardLabelBoxText: new Date(signInData.createdDateTime).toLocaleString("default", { - month: "short", - year: "numeric", - }), - }, - text: `Login ${signInData.status.errorCode === 0 ? "successful" : "failed"} from ${ - signInData.ipAddress || "unknown location" - }`, - subtext: `Logged into application ${signInData.resourceDisplayName || "Unknown Application"}`, - statusColor: signInData.status.errorCode === 0 ? "success.main" : "error.main", - statusText: signInData.status.errorCode === 0 ? "Success" : "Failed", - propertyItems: [ - { - label: "Client App Used", - value: signInData.clientAppUsed || "N/A", - }, - { - label: "Device Detail", - value: - signInData.deviceDetail?.operatingSystem || signInData.deviceDetail?.browser || "N/A", - }, - { - label: "MFA Type used", - value: signInData.mfaDetail?.authMethod || "N/A", - }, - { - label: "Additional Details", - value: signInData.status?.additionalDetails || "N/A", - }, - ], - }; - - // Prepare the conditional access policies items - if ( - signInData.appliedConditionalAccessPolicies && - Array.isArray(signInData.appliedConditionalAccessPolicies) - ) { - // Filter policies where result is "success" - const appliedPolicies = signInData.appliedConditionalAccessPolicies.filter( - (policy) => policy.result === "success" - ); - - if (appliedPolicies.length > 0) { - conditionalAccessPoliciesItems = appliedPolicies.map((policy) => ({ - id: policy.id, - cardLabelBox: { - cardLabelBoxHeader: new Date(signInData.createdDateTime).getDate().toString(), - cardLabelBoxText: new Date(signInData.createdDateTime).toLocaleString("default", { - month: "short", - year: "numeric", - }), - }, - text: policy.displayName, - subtext: `Policy applied: ${policy.result}`, - statusColor: "success.main", - statusText: "Applied", - propertyItems: [ - { - label: "Grant Controls", - value: - policy.enforcedGrantControls.length > 0 - ? policy.enforcedGrantControls.join(", ") - : "None", - }, - { - label: "Session Controls", - value: - policy.enforcedSessionControls.length > 0 - ? policy.enforcedSessionControls.join(", ") - : "None", - }, - { - label: "Conditions Satisfied", - value: policy.conditionsSatisfied || "N/A", - }, - ], - })); - } else { - // No applied policies - conditionalAccessPoliciesItems = [ - { - id: 1, - cardLabelBox: { - cardLabelBoxHeader: new Date(signInData.createdDateTime).getDate().toString(), - cardLabelBoxText: new Date(signInData.createdDateTime).toLocaleString("default", { - month: "short", - year: "numeric", - }), - }, - text: "No conditional access policies applied", - subtext: "No conditional access policies were applied during this sign-in.", - statusColor: "warning.main", - statusText: "No Policies Applied", - propertyItems: [], - }, - ]; - } - } else { - // appliedConditionalAccessPolicies is missing or not an array - conditionalAccessPoliciesItems = [ - { - id: 1, - cardLabelBox: { - cardLabelBoxHeader: new Date(signInData.createdDateTime).getDate().toString(), - cardLabelBoxText: new Date(signInData.createdDateTime).toLocaleString("default", { - month: "short", - year: "numeric", - }), - }, - text: "No conditional access policies available", - subtext: "No conditional access policies data is available for this sign-in.", - statusColor: "warning.main", - statusText: "No Data", - propertyItems: [], - }, - ]; - } - } else if (signInLogs.isError) { - signInLogItem = { - id: 1, - cardLabelBox: "!", - text: "Error loading sign-in logs. Do you have a P1 license?", - subtext: signInLogs.error.message, - statusColor: "error.main", - statusText: "Error", - propertyItems: [], - }; - - // Handle error for conditional access policies - conditionalAccessPoliciesItems = [ - { - id: 1, - cardLabelBox: "!", - text: "Error loading conditional access policies. Do you have a P1 license?", - subtext: signInLogs.error.message, - statusColor: "error.main", - statusText: "Error", - propertyItems: [], - }, - ]; - } else if (signInLogs.isSuccess && (!signInLogs.data || signInLogs.data.length === 0)) { - signInLogItem = { - id: 1, - cardLabelBox: "-", - text: "No sign-in logs available", - subtext: - "There are no sign-in logs for this user, or you do not have a P1 license to detect this data.", - statusColor: "warning.main", - statusText: "No Data", - propertyItems: [], - }; - - conditionalAccessPoliciesItems = [ - { - id: 1, - cardLabelBox: "-", - text: "No conditional access policies available", - subtext: - "There are no conditional access policies for this user, or you do not have a P1 license to detect this data.", - statusColor: "warning.main", - statusText: "No Data", - propertyItems: [], - }, - ]; - } - - // Prepare MFA devices items - if (MFARequest.isSuccess && MFARequest.data) { - const mfaResults = MFARequest.data.Results || []; - - // Exclude password authentication method - const mfaDevices = mfaResults.filter( - (method) => method["@odata.type"] !== "#microsoft.graph.passwordAuthenticationMethod" - ); - - if (mfaDevices.length > 0) { - mfaDevicesItems = mfaDevices.map((device, index) => ({ - id: index, - cardLabelBox: { - cardLabelBoxHeader: , - }, - text: device.displayName || "MFA Device", - subtext: device.deviceTag || device.clientAppName || "Unknown device", - statusColor: "success.main", - statusText: "Enabled", - propertyItems: [ - { - label: "Device Name", - value: device.displayName || "N/A", - }, - { - label: "App Version", - value: device.phoneAppVersion || "N/A", - }, - { - label: "Created Date", - value: device.createdDateTime - ? new Date(device.createdDateTime).toLocaleString() - : "N/A", - }, - { - label: "Authentication Method", - value: device["@odata.type"]?.split(".").pop() || "N/A", - }, - ], - })); - } else { - // No MFA devices other than password - mfaDevicesItems = [ - { - id: 1, - cardLabelBox: "-", - text: "No MFA devices available", - subtext: "The user does not have any MFA devices registered.", - statusColor: "warning.main", - statusText: "No Devices", - propertyItems: [], - }, - ]; - } - } else if (MFARequest.isError) { - // Error fetching MFA devices - mfaDevicesItems = [ - { - id: 1, - cardLabelBox: "!", - text: "Error loading MFA devices", - subtext: MFARequest.error.message, - statusColor: "error.main", - statusText: "Error", - propertyItems: [], - }, - ]; - } else if (MFARequest.isSuccess && (!MFARequest.data || !MFARequest.data.Results)) { - // No MFA devices data available - mfaDevicesItems = [ - { - id: 1, - cardLabelBox: "-", - text: "No MFA devices available", - subtext: "The user does not have any MFA devices registered.", - statusColor: "warning.main", - statusText: "No Devices", - propertyItems: [], - }, - ]; - } - - return ( - - {userRequest.isLoading && } - {userRequest.isSuccess && ( - - - - - - - - Latest Logon - - Applied Conditional Access Policies - 0 ? true : false} - /> - Multi-Factor Authentication Devices - 0 ? true : false} - /> - - - - - )} - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 1d01f699c5a7..1336a740612d 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -947,13 +947,15 @@ const Page = () => { icon: , url: "/api/ExecSetMailboxRule", customDataformatter: (row, action, formData) => { - return { - ruleId: row?.Identity, + const rows = Array.isArray(row) ? row : [row]; + const result = rows.map((r) => ({ + ruleId: r?.Identity, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, - ruleName: row?.Name, + ruleName: r?.Name, Enable: true, tenantFilter: userSettingsDefaults.currentTenant, - }; + })); + return Array.isArray(row) ? result : result[0]; }, condition: (row) => row && !row.Enabled, confirmText: "Are you sure you want to enable this mailbox rule?", @@ -965,13 +967,15 @@ const Page = () => { icon: , url: "/api/ExecSetMailboxRule", customDataformatter: (row, action, formData) => { - return { - ruleId: row?.Identity, + const rows = Array.isArray(row) ? row : [row]; + const result = rows.map((r) => ({ + ruleId: r?.Identity, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, - ruleName: row?.Name, + ruleName: r?.Name, Disable: true, tenantFilter: userSettingsDefaults.currentTenant, - }; + })); + return Array.isArray(row) ? result : result[0]; }, condition: (row) => row && row.Enabled, confirmText: "Are you sure you want to disable this mailbox rule?", @@ -983,12 +987,14 @@ const Page = () => { icon: , url: "/api/ExecRemoveMailboxRule", customDataformatter: (row, action, formData) => { - return { - ruleId: row?.Identity, - ruleName: row?.Name, + const rows = Array.isArray(row) ? row : [row]; + const result = rows.map((r) => ({ + ruleId: r?.Identity, + ruleName: r?.Name, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, - }; + })); + return Array.isArray(row) ? result : result[0]; }, confirmText: "Are you sure you want to remove this mailbox rule?", multiPost: false, diff --git a/src/pages/identity/administration/users/user/index.jsx b/src/pages/identity/administration/users/user/index.jsx index 95adbe3d4fed..cce0e8f472fd 100644 --- a/src/pages/identity/administration/users/user/index.jsx +++ b/src/pages/identity/administration/users/user/index.jsx @@ -1,33 +1,43 @@ -import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; -import { useSettings } from "../../../../../hooks/use-settings"; -import { useRouter } from "next/router"; -import { ApiGetCall, ApiPostCall } from "../../../../../api/ApiCall"; -import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton"; -import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon"; -import { AdminPanelSettings, Check, Group, Mail, Fingerprint, Launch, Devices } from "@mui/icons-material"; -import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; -import tabOptions from "./tabOptions"; -import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard"; -import { Box, Stack } from "@mui/system"; -import { Grid } from "@mui/system"; -import { CippUserInfoCard } from "../../../../../components/CippCards/CippUserInfoCard"; -import { SvgIcon, Typography } from "@mui/material"; -import { CippBannerListCard } from "../../../../../components/CippCards/CippBannerListCard"; -import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; -import { useEffect, useState } from "react"; -import { useCippUserActions } from "../../../../../components/CippComponents/CippUserActions"; -import { EyeIcon, PencilIcon } from "@heroicons/react/24/outline"; -import { CippDataTable } from "../../../../../components/CippTable/CippDataTable"; -import dynamic from "next/dynamic"; -const CippMap = dynamic(() => import("../../../../../components/CippComponents/CippMap"), { +import { Layout as DashboardLayout } from '../../../../../layouts/index.js' +import { useSettings } from '../../../../../hooks/use-settings' +import { useRouter } from 'next/router' +import { ApiGetCall, ApiPostCall } from '../../../../../api/ApiCall' +import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSkeleton' +import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' +import { + AdminPanelSettings, + Check, + Group, + Mail, + Fingerprint, + Launch, + Devices, + PersonRemove, +} from '@mui/icons-material' +import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import tabOptions from './tabOptions' +import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' +import { Box, Stack } from '@mui/system' +import { Grid } from '@mui/system' +import { CippUserInfoCard } from '../../../../../components/CippCards/CippUserInfoCard' +import { SvgIcon, Typography } from '@mui/material' +import { CippBannerListCard } from '../../../../../components/CippCards/CippBannerListCard' +import { CippTimeAgo } from '../../../../../components/CippComponents/CippTimeAgo' +import { useEffect, useState } from 'react' +import { useCippUserActions } from '../../../../../components/CippComponents/CippUserActions' +import { EyeIcon, PencilIcon } from '@heroicons/react/24/outline' +import { CippDataTable } from '../../../../../components/CippTable/CippDataTable' +import dynamic from 'next/dynamic' +const CippMap = dynamic(() => import('../../../../../components/CippComponents/CippMap'), { ssr: false, -}); +}) -import { Button, Dialog, DialogTitle, DialogContent, IconButton } from "@mui/material"; -import { Close } from "@mui/icons-material"; -import { CippPropertyList } from "../../../../../components/CippComponents/CippPropertyList"; -import { CippCodeBlock } from "../../../../../components/CippComponents/CippCodeBlock"; -import { CippHead } from "../../../../../components/CippComponents/CippHead"; +import { Button, Dialog, DialogTitle, DialogContent, IconButton } from '@mui/material' +import { Close } from '@mui/icons-material' +import { CippPropertyList } from '../../../../../components/CippComponents/CippPropertyList' +import { CippCodeBlock } from '../../../../../components/CippComponents/CippCodeBlock' +import { CippHead } from '../../../../../components/CippComponents/CippHead' +import { usePermissions } from '../../../../../hooks/use-permissions' const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { return ( @@ -37,7 +47,7 @@ const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { @@ -47,16 +57,16 @@ const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { noCard={true} title="Sign-In Logs" simpleColumns={[ - "createdDateTime", - "status", - "ipAddress", - "clientAppUsed", - "resourceDisplayName", - "status.errorCode", - "location", + 'createdDateTime', + 'status', + 'ipAddress', + 'clientAppUsed', + 'resourceDisplayName', + 'status.errorCode', + 'location', ]} api={{ - url: "/api/ListUserSigninLogs", + url: '/api/ListUserSigninLogs', data: { UserId: userId, tenantFilter: tenantFilter, @@ -67,22 +77,24 @@ const SignInLogsDialog = ({ open, onClose, userId, tenantFilter }) => { /> - ); -}; + ) +} const Page = () => { - const userSettingsDefaults = useSettings(); - const router = useRouter(); - const { userId } = router.query; - const [waiting, setWaiting] = useState(false); - const [signInLogsDialogOpen, setSignInLogsDialogOpen] = useState(false); - const userActions = useCippUserActions(); + const userSettingsDefaults = useSettings() + const router = useRouter() + const { userId } = router.query + const [waiting, setWaiting] = useState(false) + const [signInLogsDialogOpen, setSignInLogsDialogOpen] = useState(false) + const userActions = useCippUserActions() + const { checkPermissions } = usePermissions() + const canWriteRole = checkPermissions(['Identity.Role.ReadWrite']) useEffect(() => { if (userId) { - setWaiting(true); + setWaiting(true) } - }, [userId]); + }, [userId]) const userRequest = ApiGetCall({ url: `/api/ListUsers?UserId=${userId}&tenantFilter=${ @@ -90,70 +102,75 @@ const Page = () => { }`, queryKey: `ListUsers-${userId}`, waiting: waiting, - }); + }) const userBulkRequest = ApiPostCall({ urlFromData: true, - }); + }) function refreshFunction() { - const userPrincipalName = userRequest.data?.[0]?.userPrincipalName; + const userPrincipalName = userRequest.data?.[0]?.userPrincipalName const requests = [ { - id: "userMemberOf", + id: 'userMemberOf', url: `/users/${userId}/memberOf`, - method: "GET", + method: 'GET', }, { - id: "mfaDevices", + id: 'mfaDevices', url: `/users/${userId}/authentication/methods?$top=99`, - method: "GET", + method: 'GET', }, { - id: "signInLogs", + id: 'signInLogs', url: `/auditLogs/signIns?$filter=(userId eq '${userId}')&$top=1`, - method: "GET", + method: 'GET', }, - ]; + ] // Only add managedDevices request if we have the userPrincipalName if (userPrincipalName) { requests.push({ - id: "managedDevices", + id: 'managedDevices', url: `/deviceManagement/managedDevices?$filter=userPrincipalName eq '${userPrincipalName}'`, - method: "GET", - }); + method: 'GET', + }) } userBulkRequest.mutate({ - url: "/api/ListGraphBulkRequest", + url: '/api/ListGraphBulkRequest', data: { Requests: requests, tenantFilter: userSettingsDefaults.currentTenant, - noPaginateIds: ["signInLogs"], + noPaginateIds: ['signInLogs'], }, - }); + }) } useEffect(() => { - if (userId && userSettingsDefaults.currentTenant && userRequest.isSuccess && !userBulkRequest.isSuccess) { - refreshFunction(); + if ( + userId && + userSettingsDefaults.currentTenant && + userRequest.isSuccess && + !userBulkRequest.isSuccess + ) { + refreshFunction() } - }, [userId, userSettingsDefaults.currentTenant, userRequest.isSuccess, userBulkRequest.isSuccess]); + }, [userId, userSettingsDefaults.currentTenant, userRequest.isSuccess, userBulkRequest.isSuccess]) - const bulkData = userBulkRequest?.data?.data ?? []; - const signInLogsData = bulkData?.find((item) => item.id === "signInLogs"); - const userMemberOfData = bulkData?.find((item) => item.id === "userMemberOf"); - const mfaDevicesData = bulkData?.find((item) => item.id === "mfaDevices"); - const managedDevicesData = bulkData?.find((item) => item.id === "managedDevices"); + const bulkData = userBulkRequest?.data?.data ?? [] + const signInLogsData = bulkData?.find((item) => item.id === 'signInLogs') + const userMemberOfData = bulkData?.find((item) => item.id === 'userMemberOf') + const mfaDevicesData = bulkData?.find((item) => item.id === 'mfaDevices') + const managedDevicesData = bulkData?.find((item) => item.id === 'managedDevices') - const signInLogs = signInLogsData?.body?.value || []; - const userMemberOf = userMemberOfData?.body?.value || []; - const mfaDevices = mfaDevicesData?.body?.value || []; - const managedDevices = managedDevicesData?.body?.value || []; + const signInLogs = signInLogsData?.body?.value || [] + const userMemberOf = userMemberOfData?.body?.value || [] + const mfaDevices = mfaDevicesData?.body?.value || [] + const managedDevices = managedDevicesData?.body?.value || [] // Set the title and subtitle for the layout - const title = userRequest.isSuccess ? userRequest.data?.[0]?.displayName : "Loading..."; + const title = userRequest.isSuccess ? userRequest.data?.[0]?.displayName : 'Loading...' const subtitle = userRequest.isSuccess ? [ @@ -174,7 +191,7 @@ const Page = () => { ), }, { - icon: , + icon: , text: ( - - } - /> + <> + + + {reportDB.controls} + + } + /> + {reportDB.syncDialog} + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/teams-share/teams/teams-activity/index.js b/src/pages/teams-share/teams/teams-activity/index.js index 2f2797a57cbb..f5fb2bb53754 100644 --- a/src/pages/teams-share/teams/teams-activity/index.js +++ b/src/pages/teams-share/teams/teams-activity/index.js @@ -1,18 +1,40 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { useCippReportDB } from "../../../../components/CippComponents/CippReportDBControls"; const Page = () => { const pageTitle = "Teams Activity List"; + const reportDB = useCippReportDB({ + apiUrl: "/api/ListTeamsActivity?type=TeamsUserActivityUser", + queryKey: "ListTeamsActivity-TeamsUserActivityUser", + cacheName: "TeamsActivity", + syncTitle: "Sync Teams Activity Report", + allowToggle: true, + defaultCached: false, + }); + return ( - + <> + + {reportDB.syncDialog} + ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/tenant/administration/alert-configuration/tabOptions.json b/src/pages/tenant/administration/alert-configuration/tabOptions.json index 0aef9ef1ba5f..480e89d7cdfe 100644 --- a/src/pages/tenant/administration/alert-configuration/tabOptions.json +++ b/src/pages/tenant/administration/alert-configuration/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Alert Configuration", - "path": "/tenant/administration/alert-configuration" + "path": "/tenant/administration/alert-configuration", + "icon": "Notifications" }, { "label": "Snoozed Alerts", - "path": "/tenant/administration/alert-configuration/snoozed-alerts" + "path": "/tenant/administration/alert-configuration/snoozed-alerts", + "icon": "ShieldMoon" } ] diff --git a/src/pages/tenant/administration/applications/app-registration/tabOptions.json b/src/pages/tenant/administration/applications/app-registration/tabOptions.json index bcc5b9cbaa61..458efb66de5f 100644 --- a/src/pages/tenant/administration/applications/app-registration/tabOptions.json +++ b/src/pages/tenant/administration/applications/app-registration/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "View App Registration", - "path": "/tenant/administration/applications/app-registration" + "path": "/tenant/administration/applications/app-registration", + "icon": "Apps" }, { "label": "Permissions", - "path": "/tenant/administration/applications/app-registration/permissions" + "path": "/tenant/administration/applications/app-registration/permissions", + "icon": "Key" } ] diff --git a/src/pages/tenant/administration/applications/enterprise-app/tabOptions.json b/src/pages/tenant/administration/applications/enterprise-app/tabOptions.json index c0d82bfd8dad..346d9639fca1 100644 --- a/src/pages/tenant/administration/applications/enterprise-app/tabOptions.json +++ b/src/pages/tenant/administration/applications/enterprise-app/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "View Enterprise App", - "path": "/tenant/administration/applications/enterprise-app" + "path": "/tenant/administration/applications/enterprise-app", + "icon": "Business" }, { "label": "Permissions", - "path": "/tenant/administration/applications/enterprise-app/permissions" + "path": "/tenant/administration/applications/enterprise-app/permissions", + "icon": "Key" } ] diff --git a/src/pages/tenant/administration/applications/tabOptions.json b/src/pages/tenant/administration/applications/tabOptions.json index 05bafb200179..2f138f75fd29 100644 --- a/src/pages/tenant/administration/applications/tabOptions.json +++ b/src/pages/tenant/administration/applications/tabOptions.json @@ -1,18 +1,22 @@ [ { "label": "Enterprise Apps", - "path": "/tenant/administration/applications/enterprise-apps" + "path": "/tenant/administration/applications/enterprise-apps", + "icon": "Business" }, { "label": "App Registrations", - "path": "/tenant/administration/applications/app-registrations" + "path": "/tenant/administration/applications/app-registrations", + "icon": "Apps" }, { "label": "Permission Sets", - "path": "/tenant/administration/applications/permission-sets" + "path": "/tenant/administration/applications/permission-sets", + "icon": "Key" }, { "label": "Application Templates", - "path": "/tenant/administration/applications/templates" + "path": "/tenant/administration/applications/templates", + "icon": "Description" } -] \ No newline at end of file +] diff --git a/src/pages/tenant/administration/audit-logs/tabOptions.json b/src/pages/tenant/administration/audit-logs/tabOptions.json index 8c90389d9c68..9c5bf289488d 100644 --- a/src/pages/tenant/administration/audit-logs/tabOptions.json +++ b/src/pages/tenant/administration/audit-logs/tabOptions.json @@ -1,14 +1,17 @@ [ { "label": "Saved Logs", - "path": "/tenant/administration/audit-logs" + "path": "/tenant/administration/audit-logs", + "icon": "Storage" }, { "label": "Log Searches", - "path": "/tenant/administration/audit-logs/searches" + "path": "/tenant/administration/audit-logs/searches", + "icon": "List" }, { "label": "Directory Audits", - "path": "/tenant/administration/audit-logs/directory-audits" + "path": "/tenant/administration/audit-logs/directory-audits", + "icon": "FactCheck" } -] \ No newline at end of file +] diff --git a/src/pages/tenant/administration/securescore/tabOptions.json b/src/pages/tenant/administration/securescore/tabOptions.json index 1f7e8795f144..b4a4460ad2ec 100644 --- a/src/pages/tenant/administration/securescore/tabOptions.json +++ b/src/pages/tenant/administration/securescore/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Tenant Overview", - "path": "/tenant/administration/securescore" + "path": "/tenant/administration/securescore", + "icon": "Dashboard" }, { "label": "Table Overview", - "path": "/tenant/administration/securescore/table" + "path": "/tenant/administration/securescore/table", + "icon": "BarChart" } ] diff --git a/src/pages/tenant/administration/tenants/groups/edit.js b/src/pages/tenant/administration/tenants/groups/edit.js index f46b1d4d47ad..4aa691a66479 100644 --- a/src/pages/tenant/administration/tenants/groups/edit.js +++ b/src/pages/tenant/administration/tenants/groups/edit.js @@ -139,6 +139,7 @@ const Page = () => { groupDescription: groupData?.Description ?? "", groupType: isDynamic ? "dynamic" : "static", ruleLogic: groupData?.RuleLogic || "and", + excludePartnerTenant: groupData?.ExcludePartnerTenant ?? false, members: !isDynamic ? groupData?.Members?.map((member) => ({ label: member.displayName, diff --git a/src/pages/tenant/administration/tenants/tabOptions.json b/src/pages/tenant/administration/tenants/tabOptions.json index f36fc14e96d0..9bb186e4211e 100644 --- a/src/pages/tenant/administration/tenants/tabOptions.json +++ b/src/pages/tenant/administration/tenants/tabOptions.json @@ -1,14 +1,17 @@ [ { "label": "Tenants", - "path": "/tenant/administration/tenants" + "path": "/tenant/administration/tenants", + "icon": "Domain" }, { "label": "Groups", - "path": "/tenant/administration/tenants/groups" + "path": "/tenant/administration/tenants/groups", + "icon": "Groups" }, { "label": "Global Variables", - "path": "/tenant/administration/tenants/global-variables" + "path": "/tenant/administration/tenants/global-variables", + "icon": "Settings" } ] diff --git a/src/pages/tenant/conditional/list-policies/edit.jsx b/src/pages/tenant/conditional/list-policies/edit.jsx new file mode 100644 index 000000000000..156cd39f12b2 --- /dev/null +++ b/src/pages/tenant/conditional/list-policies/edit.jsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from "react"; +import { Alert, Box } from "@mui/material"; +import { useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import CippFormSkeleton from "../../../../components/CippFormPages/CippFormSkeleton"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import CippCAPolicyBuilder, { + extractCAPolicyJSON, +} from "../../../../components/CippComponents/CippCAPolicyBuilder"; +import { useSettings } from "../../../../hooks/use-settings.js"; + +const EditCAPolicy = () => { + const router = useRouter(); + const { id: policyId } = router.query; + const tenantFilter = useSettings()?.currentTenant; + const [policyData, setPolicyData] = useState(null); + + const formControl = useForm({ mode: "onChange" }); + + // Fetch the current policies for this tenant + const policiesQuery = ApiGetCall({ + url: `/api/ListConditionalAccessPolicies?tenantFilter=${tenantFilter}`, + queryKey: `CAPolicies-${tenantFilter}`, + enabled: !!policyId && !!tenantFilter, + }); + + useEffect(() => { + if (policiesQuery.isSuccess && policiesQuery.data?.Results) { + const match = policiesQuery.data.Results.find((p) => p.id === policyId); + if (match?.rawjson) { + const parsed = JSON.parse(match.rawjson); + setPolicyData(parsed); + } + } + }, [policiesQuery.isSuccess, policiesQuery.data, policyId]); + + const dataFormatter = (values) => { + const cleaned = extractCAPolicyJSON(values); + return { + tenantFilter, + PolicyId: policyId, + PolicyBody: cleaned, + }; + }; + + return ( + + + {policiesQuery.isLoading ? ( + + ) : policiesQuery.isError ? ( + Error loading policies. + ) : !policyData ? ( + Policy not found for ID: {policyId} + ) : ( + + )} + + + ); +}; + +EditCAPolicy.getLayout = (page) => {page}; + +export default EditCAPolicy; diff --git a/src/pages/tenant/conditional/list-policies/index.js b/src/pages/tenant/conditional/list-policies/index.js index d7f48eac6694..1e85c99b3ebc 100644 --- a/src/pages/tenant/conditional/list-policies/index.js +++ b/src/pages/tenant/conditional/list-policies/index.js @@ -25,6 +25,13 @@ const Page = () => { // Actions configuration const actions = [ + { + label: "Edit Policy", + link: "/tenant/conditional/list-policies/edit?id=[id]", + icon: , + color: "info", + hideBulk: true, + }, { label: "Create template based on policy", type: "POST", diff --git a/src/pages/tenant/conditional/list-template/create.jsx b/src/pages/tenant/conditional/list-template/create.jsx new file mode 100644 index 000000000000..4e94e4327498 --- /dev/null +++ b/src/pages/tenant/conditional/list-template/create.jsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Box } from "@mui/material"; +import { useForm } from "react-hook-form"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import CippCAPolicyBuilder, { extractCAPolicyJSON } from "../../../../components/CippComponents/CippCAPolicyBuilder"; + +const CreateCATemplate = () => { + const formControl = useForm({ + mode: "onChange", + defaultValues: { + state: { label: "Report-only", value: "enabledForReportingButNotEnforced" }, + }, + }); + + const customDataFormatter = (values) => { + return extractCAPolicyJSON(values); + }; + + return ( + + + + + + ); +}; + +CreateCATemplate.getLayout = (page) => {page}; + +export default CreateCATemplate; diff --git a/src/pages/tenant/conditional/list-template/edit.jsx b/src/pages/tenant/conditional/list-template/edit.jsx index 9521ddae99ed..85cd5bbc6397 100644 --- a/src/pages/tenant/conditional/list-template/edit.jsx +++ b/src/pages/tenant/conditional/list-template/edit.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Alert, Box, Typography } from "@mui/material"; +import { Alert, Box, Typography, ToggleButtonGroup, ToggleButton } from "@mui/material"; import { useForm } from "react-hook-form"; import { useRouter } from "next/router"; import { Layout as DashboardLayout } from "../../../../layouts/index.js"; @@ -7,15 +7,29 @@ import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; import CippFormSkeleton from "../../../../components/CippFormPages/CippFormSkeleton"; import { ApiGetCall } from "../../../../api/ApiCall"; import CippTemplateFieldRenderer from "../../../../components/CippComponents/CippTemplateFieldRenderer"; +import CippCAPolicyBuilder, { + extractCAPolicyJSON, +} from "../../../../components/CippComponents/CippCAPolicyBuilder"; const EditCATemplate = () => { const router = useRouter(); const { GUID } = router.query; const [templateData, setTemplateData] = useState(null); const [originalData, setOriginalData] = useState(null); + const [editorMode, setEditorMode] = useState("builder"); const formControl = useForm({ mode: "onChange" }); + // When switching to builder mode, reset the form to clear any empty [] + // values that CippTemplateFieldRenderer may have injected + const handleEditorModeChange = (e, val) => { + if (!val) return; + if (val === "builder" && editorMode !== "builder") { + formControl.reset({}); + } + setEditorMode(val); + }; + // Fetch the template data const templateQuery = ApiGetCall({ url: `/api/ListCATemplates?GUID=${GUID}`, @@ -110,6 +124,14 @@ const EditCATemplate = () => { }; }; + // Build a data formatter that works for both editor modes + const builderDataFormatter = (values) => { + const cleaned = extractCAPolicyJSON(values); + return { GUID, ...cleaned }; + }; + + const activeFormatter = editorMode === "builder" ? builderDataFormatter : customDataFormatter; + return ( { queryKey={[`CATemplate-${GUID}`, "CATemplates"]} backButtonTitle="Conditional Access Templates" postUrl="/api/ExecEditTemplate?type=CATemplate" - customDataformatter={customDataFormatter} + customDataformatter={activeFormatter} formPageType="Edit" + titleButton={ + + Policy Builder + Field Editor + + } > {templateQuery.isLoading ? ( ) : templateQuery.isError || !templateData ? ( Error loading template or template not found. + ) : editorMode === "builder" ? ( + ) : ( { const pageTitle = "Available Conditional Access Templates"; @@ -42,6 +43,37 @@ const Page = () => { icon: , color: "info", }, + { + label: "Add to package", + type: "POST", + url: "/api/ExecSetPackageTag", + data: { GUID: "GUID" }, + fields: [ + { + type: "textField", + name: "Package", + label: "Package Name", + required: true, + validators: { + required: { value: true, message: "Package name is required" }, + }, + }, + ], + confirmText: "Enter the package name to assign to the selected template(s).", + multiPost: true, + icon: , + color: "info", + }, + { + label: "Remove from package", + type: "POST", + url: "/api/ExecSetPackageTag", + data: { GUID: "GUID", Remove: true }, + confirmText: "Are you sure you want to remove the selected template(s) from their package?", + multiPost: true, + icon: , + color: "warning", + }, { label: "Save to GitHub", type: "POST", @@ -110,10 +142,16 @@ const Page = () => { queryKey="ListCATemplates-table" actions={actions} offCanvas={offCanvas} - simpleColumns={["displayName", "GUID"]} + simpleColumns={["displayName", "package", "GUID"]} cardButton={ - { templateItem['TemplateList-Tags']?.addedFields?.templates || templateItem['TemplateList-Tags']?.rawData?.templates - if (templateItem['TemplateList-Tags']?.value && tagTemplates) { + if (templateItem['TemplateList-Tags']?.value && tagTemplates?.length > 0) { tagTemplates.forEach((expandedTemplate) => { const itemTemplateId = expandedTemplate.GUID const standardId = `standards.IntuneTemplate.${itemTemplateId}` @@ -248,6 +249,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, @@ -372,6 +374,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, // Use the template settings object instead of true @@ -427,7 +430,7 @@ const Page = () => { templateItem['TemplateList-Tags']?.rawData?.templates // Check if this item has TemplateList-Tags and expand them - if (templateItem['TemplateList-Tags']?.value && tagTemplates) { + if (templateItem['TemplateList-Tags']?.value && tagTemplates?.length > 0) { tagTemplates.forEach((expandedTemplate) => { const itemTemplateId = expandedTemplate.GUID const standardId = `standards.ConditionalAccessTemplate.${itemTemplateId}` @@ -504,6 +507,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, @@ -619,6 +623,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, // Use the template settings object instead of true @@ -727,6 +732,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: { displayName }, @@ -867,6 +873,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: templateSettings, @@ -1035,6 +1042,11 @@ const Page = () => { } } + // If the tenant is missing the required license, treat as compliant + if (standardObject?.LicenseAvailable === false) { + isCompliant = true + } + // Determine compliance status text based on reporting flag const complianceStatus = reportingDisabled ? 'Reporting Disabled' @@ -1061,6 +1073,7 @@ const Page = () => { TemplateId: tenantTemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, } : currentTenantStandard?.value, standardValue: standardSettings, @@ -1155,6 +1168,7 @@ const Page = () => { TemplateId: standardObject?.TemplateId, CurrentValue: standardObject?.CurrentValue, ExpectedValue: standardObject?.ExpectedValue, + LicenseAvailable: standardObject?.LicenseAvailable, }, standardValue: { templateId: itemTemplateId, @@ -1272,6 +1286,14 @@ const Page = () => { const filteredGroupedStandards = useMemo(() => { if (!groupedStandards) return {} + const isLicenseMissingStandard = (standard) => { + const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue + return ( + standard.currentTenantValue?.LicenseAvailable === false || + (typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:')) + ) + } + if (!searchQuery && filter === 'all') { return groupedStandards } @@ -1283,9 +1305,7 @@ const Page = () => { const categoryMatchesSearch = !searchQuery || category.toLowerCase().includes(searchLower) const filteredStandards = groupedStandards[category].filter((standard) => { - const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue - const hasLicenseMissing = - typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:') + const hasLicenseMissing = isLicenseMissingStandard(standard) const matchesFilter = filter === 'all' || @@ -1298,9 +1318,7 @@ const Page = () => { (filter === 'nonCompliantWithLicense' && standard.complianceStatus === 'Non-Compliant' && !hasLicenseMissing) || - (filter === 'nonCompliantWithoutLicense' && - standard.complianceStatus === 'Non-Compliant' && - hasLicenseMissing) + (filter === 'nonCompliantWithoutLicense' && hasLicenseMissing) const matchesSearch = !searchQuery || @@ -1336,52 +1354,59 @@ const Page = () => { const overriddenCount = comparisonData?.filter((standard) => standard.complianceStatus === 'Overridden').length || 0 + const isIncludedInScoring = (standard) => + standard.complianceStatus !== 'Reporting Disabled' && standard.complianceStatus !== 'Overridden' + + const isLicenseMissingStandard = (standard) => { + const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue + return ( + standard.currentTenantValue?.LicenseAvailable === false || + (typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:')) + ) + } + // Calculate license-related metrics const missingLicenseCount = - comparisonData?.filter((standard) => { - const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue - return typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:') - }).length || 0 + comparisonData?.filter( + (standard) => isIncludedInScoring(standard) && isLicenseMissingStandard(standard) + ).length || 0 const nonCompliantWithLicenseCount = comparisonData?.filter((standard) => { - const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue - return ( - standard.complianceStatus === 'Non-Compliant' && - !(typeof tenantValue === 'string' && tenantValue.startsWith('License Missing:')) - ) + return standard.complianceStatus === 'Non-Compliant' && !isLicenseMissingStandard(standard) }).length || 0 const nonCompliantWithoutLicenseCount = - comparisonData?.filter((standard) => { - const tenantValue = standard.currentTenantValue?.Value || standard.currentTenantValue - return ( - standard.complianceStatus === 'Non-Compliant' && - typeof tenantValue === 'string' && - tenantValue.startsWith('License Missing:') - ) - }).length || 0 + comparisonData?.filter((standard) => isLicenseMissingStandard(standard)).length || 0 + + const compliantWithAvailableLicenseCount = + comparisonData?.filter( + (standard) => + isIncludedInScoring(standard) && + (standard.complianceStatus === 'Compliant' || + standard.complianceStatus === 'Accepted Deviation' || + standard.complianceStatus === 'Customer Specific') && + !isLicenseMissingStandard(standard) + ).length || 0 + + const scoredStandardsCount = Math.max(allCount - reportingDisabledCount - overriddenCount, 0) const compliancePercentage = - allCount > 0 - ? Math.round( - ((compliantCount + acceptedDeviationCount) / - (allCount - reportingDisabledCount - overriddenCount || 1)) * - 100 - ) + scoredStandardsCount > 0 + ? Math.round((compliantWithAvailableLicenseCount / scoredStandardsCount) * 100) : 0 const missingLicensePercentage = - allCount > 0 + scoredStandardsCount > 0 ? Math.round((missingLicenseCount / scoredStandardsCount) * 100) : 0 + + // Combined score: standards either compliant with available licensing or blocked by missing license. + const combinedScore = + scoredStandardsCount > 0 ? Math.round( - (missingLicenseCount / (allCount - reportingDisabledCount - overriddenCount || 1)) * 100 + ((compliantWithAvailableLicenseCount + missingLicenseCount) / scoredStandardsCount) * 100 ) : 0 - // Combined score: compliance percentage + missing license percentage - // This represents the total "addressable" compliance (compliant + could be compliant if licensed) - const combinedScore = compliancePercentage + missingLicensePercentage - // Simple filter for all templates (no type filtering) const templateOptions = templates ? templates.map((template) => ({ @@ -1430,6 +1455,12 @@ const Page = () => { templateDetails.refetch() }, currentTenant, + templateTenants: Array.isArray(selectedTemplate?.tenantFilter) + ? selectedTemplate.tenantFilter + : [], + excludedTenants: Array.isArray(selectedTemplate?.excludedTenants) + ? selectedTemplate.excludedTenants + : [], }), ] @@ -1963,110 +1994,123 @@ const Page = () => { - {/* Show Expected Configuration with property-by-property breakdown */} - {standard.currentTenantValue?.ExpectedValue !== undefined ? ( - - - Expected Configuration - - {typeof standard.currentTenantValue.ExpectedValue === 'object' && - standard.currentTenantValue.ExpectedValue !== null ? ( - - {Object.entries(standard.currentTenantValue.ExpectedValue).map( - ([key, val]) => ( - - - {key} - - + {standard.currentTenantValue?.LicenseAvailable === false ? ( + }> + {typeof standard.currentTenantValue?.Value === 'string' && + standard.currentTenantValue.Value.startsWith('License Missing:') + ? standard.currentTenantValue.Value + : 'This tenant does not have the required licenses for this standard'} + + ) : ( + <> + {/* Show Expected Configuration with property-by-property breakdown */} + {standard.currentTenantValue?.ExpectedValue !== undefined ? ( + + + Expected Configuration + + {typeof standard.currentTenantValue.ExpectedValue === + 'object' && + standard.currentTenantValue.ExpectedValue !== null ? ( + + {Object.entries( + standard.currentTenantValue.ExpectedValue + ).map(([key, val]) => ( + - {val !== undefined - ? JSON.stringify(val, null, 2) - : 'Not set'} + {key} + + + {val !== undefined + ? JSON.stringify(val, null, 2) + : 'Not set'} + + - - ) + ))} + + ) : ( + + + {String(standard.currentTenantValue.ExpectedValue)} + + )} - - ) : ( - - - {String(standard.currentTenantValue.ExpectedValue)} - + ) : ( + + This data has not yet been collected. Collect the data by + selecting Refresh Data from the Actions dropdown on the top of + the page. + )} - - ) : ( - - This data has not yet been collected. Collect the data by selecting - Refresh Data from the Actions dropdown on the top of the page. - - )} - - - + + + + + )} @@ -2164,34 +2208,504 @@ const Page = () => { - {/* Existing tenant comparison content */} - {typeof standard.currentTenantValue?.Value === 'object' && - standard.currentTenantValue?.Value !== null ? ( - - {standard.complianceStatus === 'Reporting Disabled' ? ( - - Reporting is disabled for this standard in the template - configuration. - + {standard.currentTenantValue?.LicenseAvailable === false ? ( + }> + {typeof standard.currentTenantValue?.Value === 'string' && + standard.currentTenantValue.Value.startsWith('License Missing:') + ? standard.currentTenantValue.Value + : 'This tenant does not have the required licenses for this standard'} + + ) : ( + <> + {/* Existing tenant comparison content */} + {typeof standard.currentTenantValue?.Value === 'object' && + standard.currentTenantValue?.Value !== null ? ( + + {standard.complianceStatus === 'Reporting Disabled' ? ( + + Reporting is disabled for this standard in the template + configuration. + + ) : ( + <> + {standard.complianceStatus === 'Overridden' ? ( + + This setting is configured by template:{' '} + {standard.overridingTemplateName || + standard.overridingTemplateId} + + ) : standard.complianceStatus === 'Compliant' ? ( + <> + {/* Show Current value property-by-property for compliant standards */} + {standard.currentTenantValue?.CurrentValue !== + undefined ? ( + typeof standard.currentTenantValue.CurrentValue === + 'object' && + standard.currentTenantValue.CurrentValue !== null ? ( + + + Current Configuration + + {Object.entries( + standard.currentTenantValue.CurrentValue + ).map(([key, val]) => ( + + + {key} + + + + + + + {val !== undefined + ? JSON.stringify(val, null, 2) + : 'Not set'} + + + + ))} + + ) : ( + + + Current Configuration + + + + {String( + standard.currentTenantValue.CurrentValue + )} + + + + ) + ) : null} + + ) : ( + <> + {standard.currentTenantValue?.Value === false && ( + + This setting is not configured correctly + + )} + {/* Show Current value property-by-property for non-compliant standards */} + {standard.currentTenantValue?.CurrentValue !== + undefined && + (typeof standard.currentTenantValue.CurrentValue === + 'object' && + standard.currentTenantValue.CurrentValue !== null ? ( + + + Current Configuration + + {Object.entries( + standard.currentTenantValue.CurrentValue + ).map(([key, val]) => { + // Compare with expected value for this property + const expectedVal = + standard.currentTenantValue?.ExpectedValue?.[ + key + ] + const isMatch = (() => { + if (expectedVal === undefined) return false + // Deep comparison handling nested objects and case-insensitive strings + const compareDeep = (v1, v2) => { + if ( + typeof v1 === 'string' && + typeof v2 === 'string' + ) { + return ( + v1.toLowerCase() === v2.toLowerCase() + ) + } + if ( + typeof v1 === 'object' && + v1 !== null && + typeof v2 === 'object' && + v2 !== null + ) { + return ( + JSON.stringify(v1) === + JSON.stringify(v2) + ) + } + return ( + JSON.stringify(v1) === JSON.stringify(v2) + ) + } + return compareDeep(val, expectedVal) + })() + + return ( + + + {key} + + + {isMatch && ( + + + + )} + + {val !== undefined + ? JSON.stringify(val, null, 2) + : 'Not set'} + + + + ) + })} + + ) : ( + + + Current Configuration + + + + {String( + standard.currentTenantValue.CurrentValue + )} + + + + ))} + + )} + + {/* Only show values if they're not simple true/false that's already covered by the alerts above */} + {!( + standard.complianceStatus === 'Compliant' && + (standard.currentTenantValue?.Value === true || + standard.currentTenantValue?.Value === false) + ) && + Object.entries(standard.currentTenantValue) + .filter( + ([key]) => + key !== 'LastRefresh' && + key !== 'CurrentValue' && + key !== 'ExpectedValue' && + // Skip showing the Value field separately if it's just true/false + !( + key === 'Value' && + (standard.currentTenantValue?.Value === true || + standard.currentTenantValue?.Value === false) + ) + ) + .map(([key, value]) => { + const actualValue = key === 'Value' ? value : value + + const standardValueForKey = + standard.standardValue && + typeof standard.standardValue === 'object' + ? standard.standardValue[key] + : undefined + + const isDifferent = + standardValueForKey !== undefined && + JSON.stringify(actualValue) !== + JSON.stringify(standardValueForKey) + + // Format the display value + let displayValue + if (typeof value === 'object' && value !== null) { + displayValue = + value?.label || JSON.stringify(value, null, 2) + } else if (value === true) { + displayValue = 'Enabled' + } else if (value === false) { + displayValue = 'Disabled' + } else { + displayValue = String(value) + } + + return ( + + + {key}: + + + {displayValue} + + + ) + })} + + )} + ) : ( - <> - {standard.complianceStatus === 'Overridden' ? ( - + + {standard.complianceStatus === 'Reporting Disabled' ? ( + + Reporting is disabled for this standard in the template + configuration. + + ) : standard.complianceStatus === 'Overridden' ? ( + This setting is configured by template:{' '} {standard.overridingTemplateName || standard.overridingTemplateId} ) : standard.complianceStatus === 'Compliant' ? ( <> - {/* Show Current value property-by-property for compliant standards */} + {/* Show Current value property-by-property in card view */} {standard.currentTenantValue?.CurrentValue !== undefined ? ( typeof standard.currentTenantValue.CurrentValue === 'object' && @@ -2311,25 +2825,18 @@ const Page = () => { ) : ( <> - {standard.currentTenantValue?.Value === false && ( - + {(standard.currentTenantValue?.Value === false || + standard.currentTenantValue === false) && ( + This setting is not configured correctly )} - {/* Show Current value property-by-property for non-compliant standards */} - {standard.currentTenantValue?.CurrentValue !== undefined && - (typeof standard.currentTenantValue.CurrentValue === + {/* Show Current value property-by-property for non-compliant standards in card view */} + {standard.currentTenantValue?.CurrentValue !== undefined ? ( + typeof standard.currentTenantValue.CurrentValue === 'object' && standard.currentTenantValue.CurrentValue !== null ? ( - + { })} ) : ( - + { - ))} - - )} - - {/* Only show values if they're not simple true/false that's already covered by the alerts above */} - {!( - standard.complianceStatus === 'Compliant' && - (standard.currentTenantValue?.Value === true || - standard.currentTenantValue?.Value === false) - ) && - Object.entries(standard.currentTenantValue) - .filter( - ([key]) => - key !== 'LastRefresh' && - key !== 'CurrentValue' && - key !== 'ExpectedValue' && - // Skip showing the Value field separately if it's just true/false - !( - key === 'Value' && - (standard.currentTenantValue?.Value === true || - standard.currentTenantValue?.Value === false) - ) - ) - .map(([key, value]) => { - const actualValue = key === 'Value' ? value : value - - const standardValueForKey = - standard.standardValue && - typeof standard.standardValue === 'object' - ? standard.standardValue[key] - : undefined - - const isDifferent = - standardValueForKey !== undefined && - JSON.stringify(actualValue) !== - JSON.stringify(standardValueForKey) - - // Format the display value - let displayValue - if (typeof value === 'object' && value !== null) { - displayValue = - value?.label || JSON.stringify(value, null, 2) - } else if (value === true) { - displayValue = 'Enabled' - } else if (value === false) { - displayValue = 'Disabled' - } else { - displayValue = String(value) - } - - return ( - - - {key}: - - - {displayValue} - - ) - })} - - )} - - ) : ( - - {standard.complianceStatus === 'Reporting Disabled' ? ( - - Reporting is disabled for this standard in the template - configuration. - - ) : standard.complianceStatus === 'Overridden' ? ( - - This setting is configured by template:{' '} - {standard.overridingTemplateName || - standard.overridingTemplateId} - - ) : standard.complianceStatus === 'Compliant' ? ( - <> - {/* Show Current value property-by-property in card view */} - {standard.currentTenantValue?.CurrentValue !== undefined ? ( - typeof standard.currentTenantValue.CurrentValue === - 'object' && - standard.currentTenantValue.CurrentValue !== null ? ( - - - Current Configuration - - {Object.entries( - standard.currentTenantValue.CurrentValue - ).map(([key, val]) => ( - - - {key} - - - - - - - {val !== undefined - ? JSON.stringify(val, null, 2) - : 'Not set'} - - - - ))} - - ) : ( - - - Current Configuration - - - - {String(standard.currentTenantValue.CurrentValue)} - + ) : standard.currentTenantValue !== undefined && + standard.currentTenantValue?.Value !== true && + standard.currentTenantValue?.Value !== false ? ( + + {String( + standard.currentTenantValue?.Value !== undefined + ? standard.currentTenantValue?.Value + : standard.currentTenantValue + )} - - ) - ) : null} - - ) : ( - <> - {(standard.currentTenantValue?.Value === false || - standard.currentTenantValue === false) && ( - - This setting is not configured correctly - + ) : standard.currentTenantValue === undefined || + (standard.currentTenantValue?.Value === null && + standard.currentTenantValue?.CurrentValue === + undefined && + standard.currentTenantValue?.ExpectedValue === + undefined) ? ( + + This setting is not configured, or data has not been + collected. If you are getting this after data + collection, the tenant might not be licensed for this + feature + + ) : null} + )} - {/* Show Current value property-by-property for non-compliant standards in card view */} - {standard.currentTenantValue?.CurrentValue !== undefined ? ( - typeof standard.currentTenantValue.CurrentValue === - 'object' && - standard.currentTenantValue.CurrentValue !== null ? ( - - - Current Configuration - - {Object.entries( - standard.currentTenantValue.CurrentValue - ).map(([key, val]) => { - // Compare with expected value for this property - const expectedVal = - standard.currentTenantValue?.ExpectedValue?.[key] - const isMatch = (() => { - if (expectedVal === undefined) return false - // Deep comparison handling nested objects and case-insensitive strings - const compareDeep = (v1, v2) => { - if ( - typeof v1 === 'string' && - typeof v2 === 'string' - ) { - return v1.toLowerCase() === v2.toLowerCase() - } - if ( - typeof v1 === 'object' && - v1 !== null && - typeof v2 === 'object' && - v2 !== null - ) { - return JSON.stringify(v1) === JSON.stringify(v2) - } - return JSON.stringify(v1) === JSON.stringify(v2) - } - return compareDeep(val, expectedVal) - })() - - return ( - - - {key} - - - {isMatch && ( - - - - )} - - {val !== undefined - ? JSON.stringify(val, null, 2) - : 'Not set'} - - - - ) - })} - - ) : ( - - - Current Configuration - - - - {String(standard.currentTenantValue.CurrentValue)} - - - - ) - ) : standard.currentTenantValue !== undefined && - standard.currentTenantValue?.Value !== true && - standard.currentTenantValue?.Value !== false ? ( - - {String( - standard.currentTenantValue?.Value !== undefined - ? standard.currentTenantValue?.Value - : standard.currentTenantValue - )} - - ) : standard.currentTenantValue === undefined || - (standard.currentTenantValue?.Value === null && - standard.currentTenantValue?.CurrentValue === undefined && - standard.currentTenantValue?.ExpectedValue === - undefined) ? ( - - This setting is not configured, or data has not been - collected. If you are getting this after data collection, - the tenant might not be licensed for this feature - - ) : null} - + )} - + )} diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js index 8c383590ed04..df2a3869dc38 100644 --- a/src/pages/tenant/manage/drift.js +++ b/src/pages/tenant/manage/drift.js @@ -95,6 +95,12 @@ const ManageDriftPage = () => { queryKey: 'ListIntuneTemplates', }) + // API call to get all CA templates for displayName lookup + const caTemplatesApi = ApiGetCall({ + url: '/api/ListCATemplates', + queryKey: 'ListCATemplates', + }) + // API call for standards comparison (when templateId is available) const comparisonApi = ApiGetCall({ url: '/api/ListStandardsCompare', @@ -232,6 +238,14 @@ const ManageDriftPage = () => { displayName = template.TemplateList.label } } + // If not found in standardSettings, look up in all CA templates (for tag templates) + if (!displayName && Array.isArray(caTemplatesApi.data)) { + const template = caTemplatesApi.data.find((t) => t.GUID === guid) + if (template?.displayName) { + displayName = template.displayName + } + } + // If template not found, return null to filter it out later if (!displayName) { return null @@ -1362,6 +1376,7 @@ const ManageDriftPage = () => { ) // Actions for the ActionsMenu + const currentDriftTemplate = standardsApi.data?.find((t) => t.GUID === templateId) const actions = createDriftManagementActions({ templateId, onRefresh: () => { @@ -1375,6 +1390,12 @@ const ManageDriftPage = () => { setTriggerReport(true) }, currentTenant: tenantFilter, + templateTenants: Array.isArray(currentDriftTemplate?.tenantFilter) + ? currentDriftTemplate.tenantFilter + : [], + excludedTenants: Array.isArray(currentDriftTemplate?.excludedTenants) + ? currentDriftTemplate.excludedTenants + : [], }) // Effect to trigger the ExecutiveReportButton when needed diff --git a/src/pages/tenant/manage/driftManagementActions.js b/src/pages/tenant/manage/driftManagementActions.js index 5d7cd8e80488..7b3f59c7bca0 100644 --- a/src/pages/tenant/manage/driftManagementActions.js +++ b/src/pages/tenant/manage/driftManagementActions.js @@ -1,5 +1,6 @@ -import React from "react"; -import { Edit, Sync, PlayArrow, PictureAsPdf } from "@mui/icons-material"; +import React from 'react' +import { Edit, Sync, PlayArrow, PictureAsPdf } from '@mui/icons-material' +import { CippFormTemplateTenantSelector } from '../../../components/CippComponents/CippFormTemplateTenantSelector.jsx' /** * Creates the standard drift management actions array @@ -11,29 +12,31 @@ import { Edit, Sync, PlayArrow, PictureAsPdf } from "@mui/icons-material"; */ export const createDriftManagementActions = ({ templateId, - templateType = "classic", + templateType = 'classic', showEditTemplate = false, onRefresh, onGenerateReport, currentTenant, + templateTenants = [], + excludedTenants = [], }) => { const actions = [ { - label: "Refresh Data", + label: 'Refresh Data', icon: , noConfirm: true, customFunction: onRefresh, }, - ]; + ] // Add Generate Report action if handler is provided if (onGenerateReport) { actions.push({ - label: "Generate Report", + label: 'Generate Report', icon: , noConfirm: true, customFunction: onGenerateReport, - }); + }) } // Add template-specific actions if templateId is available @@ -41,57 +44,55 @@ export const createDriftManagementActions = ({ // Conditionally add Edit Template action if (showEditTemplate) { actions.push({ - label: "Edit Template", + label: 'Edit Template', icon: , - color: "info", + color: 'info', noConfirm: true, customFunction: () => { // Use Next.js router for internal navigation - import("next/router") + import('next/router') .then(({ default: router }) => { router.push( `/tenant/standards/templates/template?id=${templateId}&type=${templateType}` - ); + ) }) .catch(() => { // Fallback to window.location if router is not available - window.location.href = `/tenant/standards/templates/template?id=${templateId}&type=${templateType}`; - }); + window.location.href = `/tenant/standards/templates/template?id=${templateId}&type=${templateType}` + }) }, - }); + }) } - actions.push( - { - label: `Run Standard Now (${currentTenant || "Currently Selected Tenant"})`, - type: "GET", - url: "/api/ExecStandardsRun", - icon: , - data: { - TemplateId: templateId, - }, - confirmText: "Are you sure you want to force a run of this standard?", - multiPost: false, + actions.push({ + label: 'Run Standard Now', + type: 'GET', + url: '/api/ExecStandardsRun', + icon: , + data: { + TemplateId: templateId, }, - { - label: "Run Standard Now (All Tenants in Template)", - type: "GET", - url: "/api/ExecStandardsRun", - icon: , - data: { - TemplateId: templateId, - tenantFilter: "allTenants", - }, - confirmText: "Are you sure you want to force a run of this standard?", - multiPost: false, - } - ); + customDataformatter: (_row, _action, formData) => ({ + TemplateId: templateId, + tenantFilter: formData.tenantFilter?.value ?? formData.tenantFilter, + }), + children: ({ formHook }) => ( + + ), + confirmText: 'Are you sure you want to force a run of this standard?', + allowResubmit: true, + multiPost: false, + }) } - return actions; -}; + return actions +} /** * Default export for backward compatibility */ -export default createDriftManagementActions; +export default createDriftManagementActions diff --git a/src/pages/tenant/manage/policies-deployed.js b/src/pages/tenant/manage/policies-deployed.js index d38f68d03698..3b24143cee55 100644 --- a/src/pages/tenant/manage/policies-deployed.js +++ b/src/pages/tenant/manage/policies-deployed.js @@ -1,6 +1,6 @@ -import { Layout as DashboardLayout } from "../../../layouts/index.js"; -import { useRouter } from "next/router"; -import { Policy, Security, AdminPanelSettings, Devices, ExpandMore } from "@mui/icons-material"; +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { useRouter } from 'next/router' +import { Policy, Security, AdminPanelSettings, Devices, ExpandMore } from '@mui/icons-material' import { Box, Stack, @@ -9,34 +9,34 @@ import { AccordionSummary, AccordionDetails, Chip, -} from "@mui/material"; -import { HeaderedTabbedLayout } from "../../../layouts/HeaderedTabbedLayout"; -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 { createDriftManagementActions } from "./driftManagementActions"; -import { useSettings } from "../../../hooks/use-settings"; -import { CippAutoComplete } from "../../../components/CippComponents/CippAutocomplete"; -import { useEffect } from "react"; +} from '@mui/material' +import { HeaderedTabbedLayout } from '../../../layouts/HeaderedTabbedLayout' +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 { createDriftManagementActions } from './driftManagementActions' +import { useSettings } from '../../../hooks/use-settings' +import { CippAutoComplete } from '../../../components/CippComponents/CippAutocomplete' +import { useEffect } from 'react' const PoliciesDeployedPage = () => { - const userSettingsDefaults = useSettings(); - const router = useRouter(); - const { templateId } = router.query; - const tenantFilter = router.query.tenantFilter || userSettingsDefaults.tenantFilter; - const currentTenant = userSettingsDefaults.currentTenant; + const userSettingsDefaults = useSettings() + const router = useRouter() + const { templateId } = router.query + const tenantFilter = router.query.tenantFilter || userSettingsDefaults.tenantFilter + const currentTenant = userSettingsDefaults.currentTenant // API call to get standards template data const standardsApi = ApiGetCall({ - url: "/api/listStandardTemplates", - queryKey: "ListStandardsTemplates-Drift", - }); + url: '/api/listStandardTemplates', + queryKey: 'ListStandardsTemplates-Drift', + }) // API call to get standards comparison data const comparisonApi = ApiGetCall({ - url: "/api/ListStandardsCompare", + url: '/api/ListStandardsCompare', data: { TemplateId: templateId, TenantFilter: tenantFilter, @@ -44,66 +44,70 @@ const PoliciesDeployedPage = () => { }, queryKey: `StandardsCompare-${templateId}-${tenantFilter}`, enabled: !!templateId && !!tenantFilter, - }); + }) // API call to get drift data for deviation statuses const driftApi = ApiGetCall({ - url: "/api/listTenantDrift", + url: '/api/listTenantDrift', data: { tenantFilter: tenantFilter, standardsId: templateId, }, queryKey: `TenantDrift-${templateId}-${tenantFilter}`, enabled: !!templateId && !!tenantFilter, - }); + }) // API call to get all Intune templates for displayName lookup const intuneTemplatesApi = ApiGetCall({ - url: "/api/ListIntuneTemplates", - queryKey: "ListIntuneTemplates", - }); + url: '/api/ListIntuneTemplates', + queryKey: 'ListIntuneTemplates', + }) + + // API call to get all CA templates for displayName lookup + const caTemplatesApi = ApiGetCall({ + url: '/api/ListCATemplates', + queryKey: 'ListCATemplates', + }) // Find the current template from standards data - const currentTemplate = (standardsApi.data || []).find( - (template) => template.GUID === templateId - ); - const templateStandards = currentTemplate?.standards || {}; - const comparisonData = comparisonApi.data?.[0] || {}; + const currentTemplate = (standardsApi.data || []).find((template) => template.GUID === templateId) + const templateStandards = currentTemplate?.standards || {} + const comparisonData = comparisonApi.data?.[0] || {} // Helper function to get status from comparison data with deviation status const getStatus = (standardKey, templateValue = null, templateType = null) => { - const comparisonKey = `standards.${standardKey}`; - const comparisonItem = comparisonData[comparisonKey]; - const value = comparisonItem?.Value; + const comparisonKey = `standards.${standardKey}` + const comparisonItem = comparisonData[comparisonKey] + const value = comparisonItem?.Value // If value is true, it's deployed and compliant if (value === true) { - return "Deployed"; + return 'Deployed' } // Check if ExpectedValue and CurrentValue match (like drift.js does) if (comparisonItem?.ExpectedValue && comparisonItem?.CurrentValue) { try { - const expectedStr = JSON.stringify(comparisonItem.ExpectedValue); - const currentStr = JSON.stringify(comparisonItem.CurrentValue); + const expectedStr = JSON.stringify(comparisonItem.ExpectedValue) + const currentStr = JSON.stringify(comparisonItem.CurrentValue) if (expectedStr === currentStr) { - return "Deployed"; + return 'Deployed' } } catch (e) { - console.error("Error comparing values:", e); + console.error('Error comparing values:', e) } } // If value is explicitly false, it means not deployed (not a deviation) if (value === false) { - return "Not Deployed"; + return 'Not Deployed' } // If value is null/undefined, check drift data for deviation status - const driftData = Array.isArray(driftApi.data) ? driftApi.data : []; + const driftData = Array.isArray(driftApi.data) ? driftApi.data : [] // For templates, we need to match against the full template path - let searchKeys = [standardKey, `standards.${standardKey}`]; + let searchKeys = [standardKey, `standards.${standardKey}`] // Add template-specific search keys if (templateValue && templateType) { @@ -111,7 +115,7 @@ const PoliciesDeployedPage = () => { `standards.${templateType}.${templateValue}`, `${templateType}.${templateValue}`, templateValue - ); + ) } const deviation = driftData.find((item) => @@ -122,26 +126,26 @@ const PoliciesDeployedPage = () => { item.standardName?.includes(key) || item.policyName?.includes(key) ) - ); + ) if (deviation && deviation.Status) { - return `Deviation - ${deviation.Status}`; + return `Deviation - ${deviation.Status}` } // Only return "Deviation - New" if we have comparison data but value is null if (comparisonItem) { - return "Deviation - New"; + return 'Deviation - New' } - return "Not Configured"; - }; + return 'Not Configured' + } // Helper function to get display name from drift data const getDisplayNameFromDrift = (standardKey, templateValue = null, templateType = null) => { - const driftData = Array.isArray(driftApi.data) ? driftApi.data : []; + const driftData = Array.isArray(driftApi.data) ? driftApi.data : [] // For templates, we need to match against the full template path - let searchKeys = [standardKey, `standards.${standardKey}`]; + let searchKeys = [standardKey, `standards.${standardKey}`] // Add template-specific search keys if (templateValue && templateType) { @@ -149,7 +153,7 @@ const PoliciesDeployedPage = () => { `standards.${templateType}.${templateValue}`, `${templateType}.${templateValue}`, templateValue - ); + ) } const deviation = driftData.find((item) => @@ -160,277 +164,285 @@ const PoliciesDeployedPage = () => { item.standardName?.includes(key) || item.policyName?.includes(key) ) - ); + ) // If found in drift data, return the display name if (deviation?.standardDisplayName) { - return deviation.standardDisplayName; + return deviation.standardDisplayName } // If not found in drift data and this is an Intune template, look it up in the Intune templates API - if (templateType === "IntuneTemplate" && templateValue && intuneTemplatesApi.data) { - const template = intuneTemplatesApi.data.find((t) => t.GUID === templateValue); + if (templateType === 'IntuneTemplate' && templateValue && intuneTemplatesApi.data) { + const template = intuneTemplatesApi.data.find((t) => t.GUID === templateValue) if (template?.Displayname) { - return template.Displayname; + return template.Displayname + } + } + + // If not found in drift data and this is a CA template, look it up in the CA templates API + if (templateType === 'ConditionalAccessTemplate' && templateValue && caTemplatesApi.data) { + const template = caTemplatesApi.data.find((t) => t.GUID === templateValue) + if (template?.displayName) { + return template.displayName } } - return null; - }; + return null + } // Helper function to get last refresh date const getLastRefresh = (standardKey) => { - const comparisonKey = `standards.${standardKey}`; - const lastRefresh = comparisonData[comparisonKey]?.LastRefresh; - return lastRefresh ? new Date(lastRefresh).toLocaleDateString() : "N/A"; - }; + const comparisonKey = `standards.${standardKey}` + const lastRefresh = comparisonData[comparisonKey]?.LastRefresh + return lastRefresh ? new Date(lastRefresh).toLocaleDateString() : 'N/A' + } // Helper function to get standard name from standards.json const getStandardName = (standardKey) => { - const standardName = `standards.${standardKey}`; - const standard = standardsData.find((s) => s.name === standardName); - return standard?.label || standardKey.replace(/([A-Z])/g, " $1").trim(); - }; + const standardName = `standards.${standardKey}` + const standard = standardsData.find((s) => s.name === standardName) + return standard?.label || standardKey.replace(/([A-Z])/g, ' $1').trim() + } // Helper function to get template label from standards API data const getTemplateLabel = (templateValue, templateType) => { - if (!templateValue || !currentTemplate) return "Unknown Template"; + if (!templateValue || !currentTemplate) return 'Unknown Template' // Search through all templates in the current template data - const allTemplates = currentTemplate.standards || {}; + const allTemplates = currentTemplate.standards || {} // Look for the template in the specific type array if (allTemplates[templateType] && Array.isArray(allTemplates[templateType])) { const template = allTemplates[templateType].find( (t) => t.TemplateList?.value === templateValue - ); + ) if (template?.TemplateList?.label) { - return template.TemplateList.label; + return template.TemplateList.label } } // If not found in the specific type, search through all template types for (const [key, templates] of Object.entries(allTemplates)) { if (Array.isArray(templates)) { - const template = templates.find((t) => t.TemplateList?.value === templateValue); + const template = templates.find((t) => t.TemplateList?.value === templateValue) if (template?.TemplateList?.label) { - return template.TemplateList.label; + return template.TemplateList.label } } } - return "Unknown Template"; - }; + return 'Unknown Template' + } // Process Security Standards (everything NOT IntuneTemplates or ConditionalAccessTemplates) const deployedStandards = Object.entries(templateStandards) - .filter(([key]) => key !== "IntuneTemplate" && key !== "ConditionalAccessTemplate") + .filter(([key]) => key !== 'IntuneTemplate' && key !== 'ConditionalAccessTemplate') .map(([key, value], index) => ({ id: index + 1, name: getStandardName(key), - category: "Security Standard", + category: 'Security Standard', status: getStatus(key), lastModified: getLastRefresh(key), standardKey: key, - })); + })) // Process Intune Templates - const intunePolices = []; - (templateStandards.IntuneTemplate || []).forEach((template, index) => { - console.log("Processing IntuneTemplate in policies-deployed:", template); + const intunePolices = [] + ;(templateStandards.IntuneTemplate || []).forEach((template, index) => { + console.log('Processing IntuneTemplate in policies-deployed:', template) // Check if this template has TemplateList-Tags (try both property formats) - const templateListTags = template["TemplateList-Tags"] || template.TemplateListTags; + const templateListTags = template['TemplateList-Tags'] || template.TemplateListTags // Check if this template has TemplateList-Tags and expand them - if (templateListTags?.value && templateListTags?.addedFields?.templates) { + if (templateListTags?.value && templateListTags?.addedFields?.templates?.length > 0) { console.log( - "Found TemplateList-Tags for IntuneTemplate in policies-deployed:", + 'Found TemplateList-Tags for IntuneTemplate in policies-deployed:', templateListTags - ); - console.log("Templates to expand:", templateListTags.addedFields.templates); + ) + console.log('Templates to expand:', templateListTags.addedFields.templates) // Expand TemplateList-Tags into multiple template items templateListTags.addedFields.templates.forEach((expandedTemplate, expandedIndex) => { - console.log("Expanding IntuneTemplate in policies-deployed:", expandedTemplate); - const standardKey = `IntuneTemplate.${expandedTemplate.GUID}`; + console.log('Expanding IntuneTemplate in policies-deployed:', expandedTemplate) + const standardKey = `IntuneTemplate.${expandedTemplate.GUID}` const driftDisplayName = getDisplayNameFromDrift( standardKey, expandedTemplate.GUID, - "IntuneTemplate" - ); - const packageTagName = templateListTags.value; + 'IntuneTemplate' + ) + const packageTagName = templateListTags.value const templateName = - expandedTemplate.displayName || expandedTemplate.name || "Unknown Template"; + expandedTemplate.displayName || expandedTemplate.name || 'Unknown Template' intunePolices.push({ id: intunePolices.length + 1, name: `${driftDisplayName || templateName} (via ${packageTagName})`, - category: "Intune Template", - platform: "Multi-Platform", - status: getStatus(standardKey, expandedTemplate.GUID, "IntuneTemplate"), + category: 'Intune Template', + platform: 'Multi-Platform', + status: getStatus(standardKey, expandedTemplate.GUID, 'IntuneTemplate'), lastModified: getLastRefresh(standardKey), - assignedGroups: template.AssignTo || "N/A", + assignedGroups: template.AssignTo || 'N/A', templateValue: expandedTemplate.GUID, - }); - }); + }) + }) } else { // Regular TemplateList processing - const templateGuid = template.TemplateList?.value; - const standardKey = `IntuneTemplate.${templateGuid}`; - const driftDisplayName = getDisplayNameFromDrift(standardKey, templateGuid, "IntuneTemplate"); + const templateGuid = template.TemplateList?.value + const standardKey = `IntuneTemplate.${templateGuid}` + const driftDisplayName = getDisplayNameFromDrift(standardKey, templateGuid, 'IntuneTemplate') // Try multiple fallbacks for the name - let templateName = driftDisplayName; + let templateName = driftDisplayName if (!templateName) { - const templateLabel = getTemplateLabel(templateGuid, "IntuneTemplate"); - if (templateLabel !== "Unknown Template") { - templateName = `Intune - ${templateLabel}`; + const templateLabel = getTemplateLabel(templateGuid, 'IntuneTemplate') + if (templateLabel !== 'Unknown Template') { + templateName = `Intune - ${templateLabel}` } } // If still no name, try looking up directly in intuneTemplatesApi by GUID if (!templateName && templateGuid && intuneTemplatesApi.data) { - const intuneTemplate = intuneTemplatesApi.data.find((t) => t.GUID === templateGuid); + const intuneTemplate = intuneTemplatesApi.data.find((t) => t.GUID === templateGuid) if (intuneTemplate?.Displayname) { - templateName = intuneTemplate.Displayname; + templateName = intuneTemplate.Displayname } } // Final fallback if (!templateName) { - templateName = `Intune - ${templateGuid || "Unknown Template"}`; + templateName = `Intune - ${templateGuid || 'Unknown Template'}` } intunePolices.push({ id: intunePolices.length + 1, name: templateName, - category: "Intune Template", - platform: "Multi-Platform", - status: getStatus(standardKey, templateGuid, "IntuneTemplate"), + category: 'Intune Template', + platform: 'Multi-Platform', + status: getStatus(standardKey, templateGuid, 'IntuneTemplate'), lastModified: getLastRefresh(standardKey), - assignedGroups: template.AssignTo || "N/A", + assignedGroups: template.AssignTo || 'N/A', templateValue: templateGuid, - }); + }) } - }); + }) // Add any templates from comparison data that weren't in template standards (e.g., from tags) // Check for IntuneTemplate entries in comparison data Object.keys(comparisonData).forEach((key) => { - if (key.startsWith("standards.IntuneTemplate.")) { - const guid = key.replace("standards.IntuneTemplate.", ""); + if (key.startsWith('standards.IntuneTemplate.')) { + const guid = key.replace('standards.IntuneTemplate.', '') // Check if this GUID is already in our list - const alreadyExists = intunePolices.some((p) => p.templateValue === guid); + const alreadyExists = intunePolices.some((p) => p.templateValue === guid) if (!alreadyExists && comparisonData[key]?.Value === true) { - const standardKey = `IntuneTemplate.${guid}`; - const driftDisplayName = getDisplayNameFromDrift(standardKey, guid, "IntuneTemplate"); + const standardKey = `IntuneTemplate.${guid}` + const driftDisplayName = getDisplayNameFromDrift(standardKey, guid, 'IntuneTemplate') intunePolices.push({ id: intunePolices.length + 1, name: driftDisplayName || `Intune - ${guid}`, - category: "Intune Template", - platform: "Multi-Platform", - status: getStatus(standardKey, guid, "IntuneTemplate"), + category: 'Intune Template', + platform: 'Multi-Platform', + status: getStatus(standardKey, guid, 'IntuneTemplate'), lastModified: getLastRefresh(standardKey), - assignedGroups: "N/A", + assignedGroups: 'N/A', templateValue: guid, - }); + }) } } - }); + }) // Process Conditional Access Templates - const conditionalAccessPolicies = []; - (templateStandards.ConditionalAccessTemplate || []).forEach((template, index) => { - console.log("Processing ConditionalAccessTemplate in policies-deployed:", template); + const conditionalAccessPolicies = [] + ;(templateStandards.ConditionalAccessTemplate || []).forEach((template, index) => { + console.log('Processing ConditionalAccessTemplate in policies-deployed:', template) // Check if this template has TemplateList-Tags (try both property formats) - const templateListTags = template["TemplateList-Tags"] || template.TemplateListTags; + const templateListTags = template['TemplateList-Tags'] || template.TemplateListTags // Check if this template has TemplateList-Tags and expand them - if (templateListTags?.value && templateListTags?.addedFields?.templates) { + if (templateListTags?.value && templateListTags?.addedFields?.templates?.length > 0) { console.log( - "Found TemplateList-Tags for ConditionalAccessTemplate in policies-deployed:", + 'Found TemplateList-Tags for ConditionalAccessTemplate in policies-deployed:', templateListTags - ); - console.log("Templates to expand:", templateListTags.addedFields.templates); + ) + console.log('Templates to expand:', templateListTags.addedFields.templates) // Expand TemplateList-Tags into multiple template items templateListTags.addedFields.templates.forEach((expandedTemplate, expandedIndex) => { - console.log("Expanding ConditionalAccessTemplate in policies-deployed:", expandedTemplate); - const standardKey = `ConditionalAccessTemplate.${expandedTemplate.GUID}`; + console.log('Expanding ConditionalAccessTemplate in policies-deployed:', expandedTemplate) + const standardKey = `ConditionalAccessTemplate.${expandedTemplate.GUID}` const driftDisplayName = getDisplayNameFromDrift( standardKey, expandedTemplate.GUID, - "ConditionalAccessTemplate" - ); - const packageTagName = templateListTags.value; + 'ConditionalAccessTemplate' + ) + const packageTagName = templateListTags.value const templateName = - expandedTemplate.displayName || expandedTemplate.name || "Unknown Template"; + expandedTemplate.displayName || expandedTemplate.name || 'Unknown Template' conditionalAccessPolicies.push({ id: conditionalAccessPolicies.length + 1, name: `${driftDisplayName || templateName} (via ${packageTagName})`, - state: template.state || "Unknown", - conditions: "Conditional Access Policy", - controls: "Access Control", + state: template.state || 'Unknown', + conditions: 'Conditional Access Policy', + controls: 'Access Control', lastModified: getLastRefresh(standardKey), - status: getStatus(standardKey, expandedTemplate.GUID, "ConditionalAccessTemplate"), + status: getStatus(standardKey, expandedTemplate.GUID, 'ConditionalAccessTemplate'), templateValue: expandedTemplate.GUID, - }); - }); + }) + }) } else { // Regular TemplateList processing - const standardKey = `ConditionalAccessTemplate.${template.TemplateList?.value}`; + const standardKey = `ConditionalAccessTemplate.${template.TemplateList?.value}` const driftDisplayName = getDisplayNameFromDrift( standardKey, template.TemplateList?.value, - "ConditionalAccessTemplate" - ); + 'ConditionalAccessTemplate' + ) const templateLabel = getTemplateLabel( template.TemplateList?.value, - "ConditionalAccessTemplate" - ); + 'ConditionalAccessTemplate' + ) conditionalAccessPolicies.push({ id: conditionalAccessPolicies.length + 1, name: driftDisplayName || `Conditional Access - ${templateLabel}`, - state: template.state || "Unknown", - conditions: "Conditional Access Policy", - controls: "Access Control", + state: template.state || 'Unknown', + conditions: 'Conditional Access Policy', + controls: 'Access Control', lastModified: getLastRefresh(standardKey), - status: getStatus(standardKey, template.TemplateList?.value, "ConditionalAccessTemplate"), + status: getStatus(standardKey, template.TemplateList?.value, 'ConditionalAccessTemplate'), templateValue: template.TemplateList?.value, - }); + }) } - }); + }) // Add any CA templates from comparison data that weren't in template standards Object.keys(comparisonData).forEach((key) => { - if (key.startsWith("standards.ConditionalAccessTemplate.")) { - const guid = key.replace("standards.ConditionalAccessTemplate.", ""); + if (key.startsWith('standards.ConditionalAccessTemplate.')) { + const guid = key.replace('standards.ConditionalAccessTemplate.', '') // Check if this GUID is already in our list - const alreadyExists = conditionalAccessPolicies.some((p) => p.templateValue === guid); + const alreadyExists = conditionalAccessPolicies.some((p) => p.templateValue === guid) if (!alreadyExists && comparisonData[key]?.Value === true) { - const standardKey = `ConditionalAccessTemplate.${guid}`; + const standardKey = `ConditionalAccessTemplate.${guid}` const driftDisplayName = getDisplayNameFromDrift( standardKey, guid, - "ConditionalAccessTemplate" - ); + 'ConditionalAccessTemplate' + ) conditionalAccessPolicies.push({ id: conditionalAccessPolicies.length + 1, name: driftDisplayName || `Conditional Access - ${guid}`, - state: "Unknown", - conditions: "Conditional Access Policy", - controls: "Access Control", + state: 'Unknown', + conditions: 'Conditional Access Policy', + controls: 'Access Control', lastModified: getLastRefresh(standardKey), - status: getStatus(standardKey, guid, "ConditionalAccessTemplate"), + status: getStatus(standardKey, guid, 'ConditionalAccessTemplate'), templateValue: guid, - }); + }) } } - }); + }) // Simple filter for all templates (no type filtering) const templateOptions = standardsApi.data @@ -442,35 +454,41 @@ const PoliciesDeployedPage = () => { `Template ${template.GUID}`, value: template.GUID, })) - : []; + : [] // Find currently selected template const selectedTemplateOption = templateId && templateOptions.length ? templateOptions.find((option) => option.value === templateId) || null - : null; + : null // Effect to refetch APIs when templateId changes (needed for shallow routing) useEffect(() => { if (templateId) { - comparisonApi.refetch(); - driftApi.refetch(); + comparisonApi.refetch() + driftApi.refetch() } - }, [templateId]); + }, [templateId]) const actions = createDriftManagementActions({ templateId, - templateType: currentTemplate?.type || "classic", + templateType: currentTemplate?.type || 'classic', showEditTemplate: true, onRefresh: () => { - standardsApi.refetch(); - comparisonApi.refetch(); - driftApi.refetch(); + standardsApi.refetch() + comparisonApi.refetch() + driftApi.refetch() }, currentTenant, - }); - const title = "View Deployed Policies"; - const subtitle = []; + templateTenants: Array.isArray(currentTemplate?.tenantFilter) + ? currentTemplate.tenantFilter + : [], + excludedTenants: Array.isArray(currentTemplate?.excludedTenants) + ? currentTemplate.excludedTenants + : [], + }) + const title = 'View Deployed Policies' + const subtitle = [] return ( { {/* Filters Section */} - + { defaultValue={selectedTemplateOption} value={selectedTemplateOption} onChange={(selectedTemplate) => { - const query = { ...router.query }; + const query = { ...router.query } if (selectedTemplate && selectedTemplate.value) { - query.templateId = selectedTemplate.value; + query.templateId = selectedTemplate.value } else { - delete query.templateId; + delete query.templateId } router.replace( { @@ -507,7 +525,7 @@ const PoliciesDeployedPage = () => { }, undefined, { shallow: true } - ); + ) }} sx={{ width: 300 }} placeholder="Select template..." @@ -528,7 +546,7 @@ const PoliciesDeployedPage = () => { { title="Intune Templates" data={intunePolices} simpleColumns={[ - "name", - "category", - "platform", - "status", - "lastModified", - "assignedGroups", + 'name', + 'category', + 'platform', + 'status', + 'lastModified', + 'assignedGroups', ]} noCard={true} isFetching={ @@ -580,12 +598,12 @@ const PoliciesDeployedPage = () => { title="Conditional Access Templates" data={conditionalAccessPolicies} simpleColumns={[ - "name", - "state", - "status", - "conditions", - "controls", - "lastModified", + 'name', + 'state', + 'status', + 'conditions', + 'controls', + 'lastModified', ]} noCard={true} isFetching={ @@ -597,9 +615,9 @@ const PoliciesDeployedPage = () => { - ); -}; + ) +} -PoliciesDeployedPage.getLayout = (page) => {page}; +PoliciesDeployedPage.getLayout = (page) => {page} -export default PoliciesDeployedPage; +export default PoliciesDeployedPage diff --git a/src/pages/tenant/manage/tabOptions.json b/src/pages/tenant/manage/tabOptions.json index adf60d405b09..9fc74d76d3ed 100644 --- a/src/pages/tenant/manage/tabOptions.json +++ b/src/pages/tenant/manage/tabOptions.json @@ -1,30 +1,37 @@ [ { "label": "Edit Tenant", - "path": "/tenant/manage/edit" + "path": "/tenant/manage/edit", + "icon": "Settings" }, { "label": "Manage Drift", - "path": "/tenant/manage/drift" + "path": "/tenant/manage/drift", + "icon": "Sync" }, { "label": "Configuration Backup", - "path": "/tenant/manage/configuration-backup" + "path": "/tenant/manage/configuration-backup", + "icon": "Storage" }, { "label": "Applied Standards Report", - "path": "/tenant/manage/applied-standards" + "path": "/tenant/manage/applied-standards", + "icon": "FactCheck" }, { "label": "Policies and Settings Deployed", - "path": "/tenant/manage/policies-deployed" + "path": "/tenant/manage/policies-deployed", + "icon": "Policy" }, { "label": "User Defaults", - "path": "/tenant/manage/user-defaults" + "path": "/tenant/manage/user-defaults", + "icon": "Person" }, { "label": "History", - "path": "/tenant/manage/history" + "path": "/tenant/manage/history", + "icon": "Timeline" } ] diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 25fd4b63362d..62f5250922df 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -91,7 +91,8 @@ const Page = () => { labelField: 'id', valueField: 'id', queryKey: `ListGraphRequest-domains-${userSettings.currentTenant}`, - dataFilter: (options) => options.filter((option) => option?.addedFields?.isVerified === true), // Only include verified domains + dataFilter: (options) => + options.filter((option) => option?.addedFields?.isVerified === true), // Only include verified domains }, multiple: false, creatable: false, @@ -121,9 +122,9 @@ const Page = () => { api: { url: '/api/ListLicenses', labelField: (option) => - `${option.License || option.skuPartNumber} (${option.AvailableUnits || 0} available)`, + `${option.License || option.skuPartNumber} (${option.availableUnits || 0} available)`, valueField: 'skuId', - queryKey: 'ListLicenses', + queryKey: `ListLicenses-${userSettings.currentTenant}`, }, multiple: true, creatable: false, @@ -136,7 +137,7 @@ const Page = () => { url: '/api/ListGroups', labelField: 'displayName', valueField: 'id', - queryKey: 'ListGroups', + queryKey: `ListGroups-${userSettings.currentTenant}`, addedField: { groupType: 'calculatedGroupType', }, @@ -194,6 +195,13 @@ const Page = () => { name: 'businessPhones[0]', type: 'textField', }, + ...(userSettings?.userAttributes + ?.filter((attribute) => attribute.value !== 'sponsor') + .map((attribute) => ({ + label: attribute.label, + name: `defaultAttributes.${attribute.label}.Value`, + type: 'textField', + })) || []), ] const actions = [ @@ -241,6 +249,9 @@ const Page = () => { 'department', 'mobilePhone', 'businessPhones', + ...(userSettings?.userAttributes + ?.filter((attribute) => attribute.value !== 'sponsor') + .map((attribute) => `defaultAttributes.${attribute.label}.Value`) || []), ], actions: actions, } diff --git a/src/pages/tenant/reports/list-licenses/index.js b/src/pages/tenant/reports/list-licenses/index.js index 35d61ea16158..61fcfc5b74b6 100644 --- a/src/pages/tenant/reports/list-licenses/index.js +++ b/src/pages/tenant/reports/list-licenses/index.js @@ -1,24 +1,92 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { AssignmentInd } from '@mui/icons-material' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' const Page = () => { - const pageTitle = "Licences Report"; - const apiUrl = "/api/ListLicenses"; + const pageTitle = 'Licences Report' + const apiUrl = '/api/ListLicensesReport' const simpleColumns = [ - "Tenant", - "License", - "CountUsed", - "CountAvailable", - "TotalLicenses", - "AssignedUsers", - "AssignedGroups", - "TermInfo", // TODO TermInfo is not showing as a clickable json object in the table, like CApolicies does in the mfa report. IDK how to fix it. -Bobby - ]; + 'Tenant', + 'License', + 'CountUsed', + 'CountAvailable', + 'TotalLicenses', + 'AssignedUsers', + 'AssignedGroups', + 'TermInfo', // TODO TermInfo is not showing as a clickable json object in the table, like CApolicies does in the mfa report. IDK how to fix it. -Bobby + ] - return ; -}; + const actions = [ + { + label: 'Assign License to User', + type: 'POST', + url: '/api/ExecBulkLicense', + icon: , + confirmText: 'Are you sure you want to assign [License] to the selected user?', + multiPost: false, + children: ({ formHook, row }) => ( + `${option.displayName} (${option.userPrincipalName})`, + valueField: 'id', + queryKey: `Users-${row?.Tenant}`, + data: { + Endpoint: 'users', + $select: 'id,displayName,userPrincipalName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, + }} + /> + ), + customDataformatter: (row, action, formData) => ({ + tenantFilter: row.Tenant, + LicenseOperation: 'Add', + Licenses: [{ label: row.License, value: row.skuId }], + userIds: [formData.userIds?.value], + }), + }, + ] -Page.getLayout = (page) => {page}; + const offCanvas = { + extendedInfoFields: [ + 'Tenant', + 'License', + 'CountUsed', + 'CountAvailable', + 'TotalLicenses', + 'AssignedUsers', + 'AssignedGroups', + 'TermInfo', + ], + actions: actions, + } -export default Page; + return ( + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/src/pages/tenant/standards/alignment/index.js b/src/pages/tenant/standards/alignment/index.js index e81ce99d92e7..60c05489bc24 100644 --- a/src/pages/tenant/standards/alignment/index.js +++ b/src/pages/tenant/standards/alignment/index.js @@ -1,16 +1,248 @@ import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import { ApiGetCallWithPagination } from '../../../../api/ApiCall' +import { useSettings } from '../../../../hooks/use-settings' import { Delete, Edit } from '@mui/icons-material' -import { EyeIcon, ListBulletIcon, ChartBarIcon } from '@heroicons/react/24/outline' +import { EyeIcon, ListBulletIcon, ChartBarIcon, Squares2X2Icon } from '@heroicons/react/24/outline' import tabOptions from '../tabOptions.json' -import { useState } from 'react' -import { Box, Chip, Divider, Stack, Tooltip, Typography } from '@mui/material' +import { useEffect, useMemo, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { + Box, + Chip, + Divider, + Stack, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from '@mui/material' import standardsData from '../../../../data/standards.json' +const complianceColors = { + compliant: 'success', + 'non-compliant': 'error', + 'accepted deviation': 'info', + 'customer specific': 'info', + 'license missing': 'warning', + 'reporting disabled': 'default', +} + +const compliancePriority = { + compliant: 10, + 'reporting disabled': 20, + 'customer specific': 30, + 'accepted deviation': 40, + 'license missing': 50, + 'non-compliant': 60, +} + +const getComplianceStatus = (status) => String(status ?? 'Unknown').trim() || 'Unknown' + +const getComplianceColor = (status) => + complianceColors[getComplianceStatus(status).toLowerCase()] ?? 'default' + +const getCompliancePriority = (status) => + compliancePriority[getComplianceStatus(status).toLowerCase()] ?? 0 + +const isAlignedComplianceStatus = (status) => + ['compliant', 'accepted deviation', 'customer specific'].includes( + getComplianceStatus(status).toLowerCase() + ) + +const getPageRows = (page) => { + if (Array.isArray(page)) return page + if (Array.isArray(page?.Results)) return page.Results + if (Array.isArray(page?.Data)) return page.Data + if (Array.isArray(page?.data)) return page.data + if (Array.isArray(page?.value)) return page.value + return [] +} + +const getStandardInfo = (standardId) => { + const baseName = standardId?.split('.').slice(0, -1).join('.') + return ( + standardsData.find((s) => s.name === standardId) ?? + standardsData.find((s) => s.name === baseName) + ) +} + const Page = () => { const pageTitle = 'Standard & Drift Alignment' - const [granular, setGranular] = useState(false) + const tenant = useSettings().currentTenant + const [viewMode, setViewMode] = useState('summary') + const [byStandardTenantFilter, setByStandardTenantFilter] = useState('all') + const isSummary = viewMode === 'summary' + const isGranular = viewMode === 'granular' + const isByStandard = viewMode === 'byStandard' + + const { + data: byStandardApiData, + fetchNextPage: fetchNextByStandardPage, + hasNextPage: byStandardHasNextPage, + isFetching: byStandardIsFetching, + isSuccess: byStandardIsSuccess, + } = ApiGetCallWithPagination({ + url: '/api/ListTenantAlignment', + data: { tenantFilter: tenant, granular: 'true' }, + queryKey: `listTenantAlignment-byStandard-source-${tenant}`, + waiting: isByStandard, + }) + + useEffect(() => { + if (isByStandard && byStandardIsSuccess && byStandardHasNextPage && !byStandardIsFetching) { + fetchNextByStandardPage() + } + }, [ + byStandardApiData?.pages?.length, + byStandardHasNextPage, + byStandardIsFetching, + byStandardIsSuccess, + fetchNextByStandardPage, + isByStandard, + ]) + + const byStandardSourceData = useMemo( + () => byStandardApiData?.pages?.flatMap((page) => getPageRows(page)) ?? [], + [byStandardApiData] + ) + + const byStandardData = useMemo(() => { + const groupedStandards = new Map() + + byStandardSourceData.forEach((row) => { + const standardKey = row.standardId || row.standardName + if (!standardKey) return + + const standardInfo = getStandardInfo(row.standardId) + const hasExactMatch = standardsData.find((s) => s.name === row.standardId) + const standardName = hasExactMatch + ? (standardInfo?.label ?? row.standardName ?? standardKey) + : (row.standardName ?? standardInfo?.label ?? standardKey) + + if (!groupedStandards.has(standardKey)) { + groupedStandards.set(standardKey, { + standardId: standardKey, + standardName, + category: standardInfo?.cat ?? 'Uncategorized', + standardTypes: new Set(), + tenants: new Map(), + }) + } + + const standard = groupedStandards.get(standardKey) + const standardType = row.standardType ?? row.templateType + if (standardType) standard.standardTypes.add(standardType) + + const tenantKey = row.tenantFilter ?? row.tenantName ?? row.Tenant ?? 'Unknown' + const status = getComplianceStatus(row.complianceStatus) + const tenant = standard.tenants.get(tenantKey) ?? { + tenantFilter: tenantKey, + complianceStatus: status, + rows: [], + } + + tenant.rows.push(row) + if (getCompliancePriority(status) > getCompliancePriority(tenant.complianceStatus)) { + tenant.complianceStatus = status + } + standard.tenants.set(tenantKey, tenant) + }) + + return Array.from(groupedStandards.values()) + .map((standard) => { + const tenants = Array.from(standard.tenants.values()) + .map((tenant) => { + const templateNames = [ + ...new Set(tenant.rows.map((row) => row.templateName).filter(Boolean)), + ] + const latestDataCollection = tenant.rows + .map((row) => row.latestDataCollection) + .filter(Boolean) + .sort((a, b) => new Date(b) - new Date(a))[0] + + return { + tenantFilter: tenant.tenantFilter, + complianceStatus: tenant.complianceStatus, + templateName: templateNames.join(', ') || 'N/A', + latestDataCollection, + rowCount: tenant.rows.length, + rows: tenant.rows, + } + }) + .sort((a, b) => a.tenantFilter.localeCompare(b.tenantFilter)) + + const counts = tenants.reduce( + (acc, tenant) => { + switch (getComplianceStatus(tenant.complianceStatus).toLowerCase()) { + case 'compliant': + acc.compliantCount += 1 + break + case 'non-compliant': + acc.nonCompliantCount += 1 + break + case 'accepted deviation': + acc.acceptedDeviationCount += 1 + break + case 'customer specific': + acc.customerSpecificCount += 1 + break + case 'license missing': + acc.licenseMissingCount += 1 + break + case 'reporting disabled': + acc.reportingDisabledCount += 1 + break + default: + acc.otherCount += 1 + } + return acc + }, + { + compliantCount: 0, + nonCompliantCount: 0, + acceptedDeviationCount: 0, + customerSpecificCount: 0, + licenseMissingCount: 0, + reportingDisabledCount: 0, + otherCount: 0, + } + ) + + const totalTenants = tenants.length + const alignedCount = + counts.compliantCount + counts.acceptedDeviationCount + counts.customerSpecificCount + const compliancePercentage = totalTenants + ? Math.round((alignedCount / totalTenants) * 100) + : 0 + const licenseMissingPercentage = totalTenants + ? Math.round((counts.licenseMissingCount / totalTenants) * 100) + : 0 + + return { + standardId: standard.standardId, + standardName: standard.standardName, + category: standard.category, + standardType: Array.from(standard.standardTypes).sort().join(', ') || 'N/A', + totalTenants, + alignedCount, + compliancePercentage, + alignmentScore: compliancePercentage, + LicenseMissingPercentage: licenseMissingPercentage, + complianceScore: `${compliancePercentage}%`, + summaryStatus: compliancePercentage === 100 ? 'Fully Compliant' : 'Needs Attention', + hasNonCompliant: counts.nonCompliantCount > 0 ? 'Yes' : 'No', + hasLicenseMissing: counts.licenseMissingCount > 0 ? 'Yes' : 'No', + hasAcceptedDeviation: counts.acceptedDeviationCount > 0 ? 'Yes' : 'No', + isFullyCompliant: compliancePercentage === 100 ? 'Yes' : 'No', + tenants, + ...counts, + } + }) + .sort((a, b) => a.standardName.localeCompare(b.standardName)) + }, [byStandardSourceData]) const summaryFilterList = [ { @@ -53,6 +285,29 @@ const Page = () => { }, ] + const byStandardFilterList = [ + { + filterName: 'Fully Compliant', + value: [{ id: 'isFullyCompliant', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'Has Non-Compliant', + value: [{ id: 'hasNonCompliant', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'License Missing', + value: [{ id: 'hasLicenseMissing', value: 'Yes' }], + type: 'column', + }, + { + filterName: 'Accepted Deviation', + value: [{ id: 'hasAcceptedDeviation', value: 'Yes' }], + type: 'column', + }, + ] + const summaryActions = [ { label: 'View Tenant Report', @@ -178,16 +433,7 @@ const Page = () => { standardsData.find((s) => s.name === baseName)?.label ?? row.standardName - const complianceColors = { - compliant: 'success', - 'non-compliant': 'error', - 'accepted deviation': 'info', - 'customer specific': 'info', - 'license missing': 'warning', - 'reporting disabled': 'default', - } - const statusColor = - complianceColors[String(row.complianceStatus ?? '').toLowerCase()] ?? 'default' + const statusColor = getComplianceColor(row.complianceStatus) const properties = [ { label: 'Standard', value: prettyName }, @@ -434,39 +680,245 @@ const Page = () => { }, } + const byStandardOffCanvas = { + size: 'md', + title: 'Standard Tenant Summary', + contentPadding: 0, + children: (row) => { + const standardInfo = getStandardInfo(row.standardId) + const properties = [ + { label: 'Standard', value: row.standardName }, + { label: 'Category', value: row.category }, + { label: 'Type', value: row.standardType }, + { label: 'Tenants', value: row.totalTenants }, + { label: 'Compliance', value: `${row.alignmentScore}%` }, + { label: 'Licenses Missing', value: `${row.LicenseMissingPercentage}%` }, + ] + const tenants = row.tenants ?? [] + const compliantTenants = tenants.filter((tenant) => + isAlignedComplianceStatus(tenant.complianceStatus) + ) + const nonCompliantTenants = tenants.filter( + (tenant) => !isAlignedComplianceStatus(tenant.complianceStatus) + ) + const filteredTenants = + byStandardTenantFilter === 'compliant' + ? compliantTenants + : byStandardTenantFilter === 'nonCompliant' + ? nonCompliantTenants + : tenants + + return ( + + } + sx={{ borderBottom: '1px solid', borderColor: 'divider' }} + > + {properties.map(({ label, value }) => ( + + + {label} + + + {value ?? 'N/A'} + + + ))} + + + {standardInfo?.helpText && ( + + + Description + + + {standardInfo.helpText} + + + )} + + + + + Tenant Compliance + + { + if (newFilter !== null) setByStandardTenantFilter(newFilter) + }} + sx={{ + alignSelf: { xs: 'flex-start', sm: 'center' }, + '& .MuiToggleButton-root': { py: 0.25, px: 1, fontSize: '0.75rem' }, + }} + > + All ({tenants.length}) + Compliant ({compliantTenants.length}) + + Noncompliant ({nonCompliantTenants.length}) + + + + {filteredTenants.length === 0 && ( + + No tenants match this filter. + + )} + {filteredTenants.map((tenant) => ( + + + + + {tenant.tenantFilter} + + + Template: {tenant.templateName} + + + + + + Last Applied:{' '} + {tenant.latestDataCollection + ? new Date(tenant.latestDataCollection).toLocaleString() + : 'N/A'} + {tenant.rowCount > 1 ? ` (${tenant.rowCount} template matches)` : ''} + + + ))} + + + ) + }, + } + const modeToggle = ( - - - ) : ( - - ) - } - label={granular ? 'Per Standard' : 'Summary'} - onClick={() => setGranular((v) => !v)} - color="primary" - variant="filled" - size="small" - clickable - /> - + { + if (newViewMode !== null) setViewMode(newViewMode) + }} + sx={{ '& .MuiToggleButton-root': { py: 0.25, px: 1, fontSize: '0.75rem' } }} + > + + + + + Summary + + + + + + + + Per Standard + + + + + + + + By Standard + + + + ) return ( { 'standardType', 'latestDataCollection', ] - : [ - 'tenantFilter', - 'standardName', - 'standardType', - 'alignmentScore', - 'LicenseMissingPercentage', - 'combinedAlignmentScore', - 'pendingDeviationsCount', - 'deniedDeviationsCount', - ] + : isByStandard + ? [ + 'standardName', + 'category', + 'standardType', + 'totalTenants', + 'tenants', + 'compliancePercentage', + 'LicenseMissingPercentage', + 'alignedCount', + 'compliantCount', + 'nonCompliantCount', + 'licenseMissingCount', + 'acceptedDeviationCount', + ] + : [ + 'tenantFilter', + 'standardName', + 'standardType', + 'alignmentScore', + 'LicenseMissingPercentage', + 'combinedAlignmentScore', + 'pendingDeviationsCount', + 'deniedDeviationsCount', + ] + } + queryKey={ + isGranular + ? 'listTenantAlignment-granular' + : isByStandard + ? 'listTenantAlignment-byStandard' + : 'listTenantAlignment' } - queryKey={granular ? 'listTenantAlignment-granular' : 'listTenantAlignment'} - offCanvas={granular ? granularOffCanvas : undefined} + offCanvas={isGranular ? granularOffCanvas : isByStandard ? byStandardOffCanvas : undefined} + offCanvasOnRowClick={isByStandard} cardButton={modeToggle} /> ) diff --git a/src/pages/tenant/standards/bpa-report/view.js b/src/pages/tenant/standards/bpa-report/view.js index f85fb633a3a3..6abd0203b330 100644 --- a/src/pages/tenant/standards/bpa-report/view.js +++ b/src/pages/tenant/standards/bpa-report/view.js @@ -10,7 +10,7 @@ import { useEffect, useState } from "react"; import CippButtonCard from "../../../../components/CippCards/CippButtonCard"; import { CippDataTable } from "../../../../components/CippTable/CippDataTable"; import { CippImageCard } from "../../../../components/CippCards/CippImageCard"; -import _ from "lodash"; +import { get } from "lodash"; const Page = () => { const router = useRouter(); const { id } = router.query; @@ -52,8 +52,8 @@ const Page = () => { const tenantData = bpaData?.data?.Data?.find((data) => data.GUID === tenantId); const cards = frontendFields.map((field) => { //instead of this, use lodash to get the data for blockData - const blockData = _.get(tenantData, field.value) - ? _.get(tenantData, field.value) + const blockData = get(tenantData, field.value) + ? get(tenantData, field.value) : undefined; return { name: field.name, diff --git a/src/pages/tenant/standards/tabOptions.json b/src/pages/tenant/standards/tabOptions.json index 26c6c751dc1a..59c2cfa5df76 100644 --- a/src/pages/tenant/standards/tabOptions.json +++ b/src/pages/tenant/standards/tabOptions.json @@ -1,10 +1,12 @@ [ { "label": "Standard & Drift Alignment", - "path": "/tenant/standards/alignment" + "path": "/tenant/standards/alignment", + "icon": "FactCheck" }, { "label": "Templates", - "path": "/tenant/standards/templates" + "path": "/tenant/standards/templates", + "icon": "Description" } -] \ No newline at end of file +] diff --git a/src/pages/tenant/standards/templates/index.js b/src/pages/tenant/standards/templates/index.js index b23752c50d9b..e6b82787f7db 100644 --- a/src/pages/tenant/standards/templates/index.js +++ b/src/pages/tenant/standards/templates/index.js @@ -1,152 +1,151 @@ -import { Alert, Button } from "@mui/material"; -import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative. -import { TabbedLayout } from "../../../../layouts/TabbedLayout"; -import Link from "next/link"; -import { CopyAll, Delete, PlayArrow, AddBox, Edit, GitHub, ContentCopy } from "@mui/icons-material"; -import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; -import { Grid } from "@mui/system"; -import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; -import { EyeIcon } from "@heroicons/react/24/outline"; -import tabOptions from "../tabOptions.json"; -import { useSettings } from "../../../../hooks/use-settings.js"; -import { CippPolicyImportDrawer } from "../../../../components/CippComponents/CippPolicyImportDrawer.jsx"; -import { PermissionButton } from "../../../../utils/permissions.js"; +import { Alert, Button } from '@mui/material' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative. +import { TabbedLayout } from '../../../../layouts/TabbedLayout' +import Link from 'next/link' +import { CopyAll, Delete, PlayArrow, AddBox, Edit, GitHub, ContentCopy } from '@mui/icons-material' +import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall' +import { Grid } from '@mui/system' +import { CippApiResults } from '../../../../components/CippComponents/CippApiResults' +import { EyeIcon } from '@heroicons/react/24/outline' +import tabOptions from '../tabOptions.json' +import { CippPolicyImportDrawer } from '../../../../components/CippComponents/CippPolicyImportDrawer.jsx' +import { PermissionButton } from '../../../../utils/permissions.js' +import { CippFormTemplateTenantSelector } from '../../../../components/CippComponents/CippFormTemplateTenantSelector.jsx' const Page = () => { - const oldStandards = ApiGetCall({ url: "/api/ListStandards", queryKey: "ListStandards-legacy" }); + const oldStandards = ApiGetCall({ url: '/api/ListStandards', queryKey: 'ListStandards-legacy' }) const integrations = ApiGetCall({ - url: "/api/ListExtensionsConfig", - queryKey: "Integrations", + url: '/api/ListExtensionsConfig', + queryKey: 'Integrations', refetchOnMount: false, refetchOnReconnect: false, - }); + }) - const currentTenant = useSettings().currentTenant; - const pageTitle = "Templates"; - const cardButtonPermissions = ["Tenant.Standards.ReadWrite"]; + const pageTitle = 'Templates' + const cardButtonPermissions = ['Tenant.Standards.ReadWrite'] const actions = [ { - label: "View Tenant Report", - link: "/tenant/manage/applied-standards/?templateId=[GUID]", + label: 'View Tenant Report', + link: '/tenant/manage/applied-standards/?templateId=[GUID]', icon: , - color: "info", - target: "_self", + color: 'info', + target: '_self', }, { - label: "Edit Template", + label: 'Edit Template', //when using a link it must always be the full path /identity/administration/users/[id] for example. - link: "/tenant/standards/templates/template?id=[GUID]&type=[type]", + link: '/tenant/standards/templates/template?id=[GUID]&type=[type]', icon: , - color: "success", - target: "_self", + color: 'success', + target: '_self', }, { - label: "Clone & Edit Template", - link: "/tenant/standards/templates/template?id=[GUID]&clone=true&type=[type]", + label: 'Clone & Edit Template', + link: '/tenant/standards/templates/template?id=[GUID]&clone=true&type=[type]', icon: , - color: "success", - target: "_self", + color: 'success', + target: '_self', }, { - label: "Create Drift Clone", - type: "POST", - url: "/api/ExecDriftClone", + label: 'Create Drift Clone', + type: 'POST', + url: '/api/ExecDriftClone', icon: , - color: "warning", + color: 'warning', data: { - id: "GUID", + id: 'GUID', }, confirmText: - "Are you sure you want to create a drift clone of [templateName]? This will create a new drift template based on this template.", + 'Are you sure you want to create a drift clone of [templateName]? This will create a new drift template based on this template.', multiPost: false, }, { - label: `Run Template Now (${currentTenant || "Currently Selected Tenant"})`, - type: "GET", - url: "/api/ExecStandardsRun", + label: 'Run Template Now', + type: 'GET', + url: '/api/ExecStandardsRun', icon: , data: { - TemplateId: "GUID", + TemplateId: 'GUID', }, - confirmText: "Are you sure you want to force a run of this template?", + allowResubmit: true, + customDataformatter: (row, action, formData) => ({ + TemplateId: row.GUID, + tenantFilter: formData.tenantFilter?.value ?? formData.tenantFilter, + }), + children: ({ formHook, row }) => ( + + ), + confirmText: 'Are you sure you want to force a run of this template?', multiPost: false, }, { - label: "Run Template Now (All Tenants in Template)", - type: "GET", - url: "/api/ExecStandardsRun", - icon: , - data: { - TemplateId: "GUID", - tenantFilter: "allTenants", - }, - confirmText: "Are you sure you want to force a run of this template?", - multiPost: false, - }, - { - label: "Save to GitHub", - type: "POST", - url: "/api/ExecCommunityRepo", + label: 'Save to GitHub', + type: 'POST', + url: '/api/ExecCommunityRepo', icon: , data: { - Action: "UploadTemplate", - GUID: "GUID", + Action: 'UploadTemplate', + GUID: 'GUID', }, fields: [ { - label: "Repository", - name: "FullName", - type: "select", + label: 'Repository', + name: 'FullName', + type: 'select', api: { - url: "/api/ListCommunityRepos", + url: '/api/ListCommunityRepos', data: { WriteAccess: true, }, - queryKey: "CommunityRepos-Write", - dataKey: "Results", - valueField: "FullName", - labelField: "FullName", + queryKey: 'CommunityRepos-Write', + dataKey: 'Results', + valueField: 'FullName', + labelField: 'FullName', }, multiple: false, creatable: false, required: true, validators: { - required: { value: true, message: "This field is required" }, + required: { value: true, message: 'This field is required' }, }, }, { - label: "Commit Message", - placeholder: "Enter a commit message for adding this file to GitHub", - name: "Message", - type: "textField", + label: 'Commit Message', + placeholder: 'Enter a commit message for adding this file to GitHub', + name: 'Message', + type: 'textField', multiline: true, required: true, rows: 4, }, ], - confirmText: "Are you sure you want to save this template to the selected repository?", + confirmText: 'Are you sure you want to save this template to the selected repository?', condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled, }, { - label: "Delete Template", - type: "POST", - url: "/api/RemoveStandardTemplate", + label: 'Delete Template', + type: 'POST', + url: '/api/RemoveStandardTemplate', icon: , data: { - ID: "GUID", + ID: 'GUID', }, - confirmText: "Are you sure you want to delete [templateName]?", + confirmText: 'Are you sure you want to delete [templateName]?', multiPost: false, }, - ]; - const conversionApi = ApiPostCall({ relatedQueryKeys: "listStandardTemplates" }); + ] + const conversionApi = ApiPostCall({ relatedQueryKeys: 'listStandardTemplates' }) const handleConversion = () => { conversionApi.mutate({ - url: "/api/execStandardConvert", + url: '/api/execStandardConvert', data: {}, - }); - }; + }) + } const tableFilter = (
    {oldStandards.isSuccess && oldStandards.data.length !== 0 && ( @@ -154,7 +153,7 @@ const Page = () => { You have legacy standards available. Press the button to convert these standards to @@ -163,7 +162,7 @@ const Page = () => { they are correct and re-enable the schedule. - @@ -175,7 +174,7 @@ const Page = () => { )}
    - ); + ) return ( { actions={actions} tableFilter={tableFilter} simpleColumns={[ - "templateName", - "type", - "tenantFilter", - "excludedTenants", - "updatedAt", - "updatedBy", - "runManually", - "standards", + 'templateName', + 'type', + 'tenantFilter', + 'excludedTenants', + 'updatedAt', + 'updatedBy', + 'runManually', + 'standards', ]} queryKey="listStandardTemplates" /> - ); -}; + ) +} Page.getLayout = (page) => ( {page} -); +) -export default Page; +export default Page diff --git a/src/pages/tenant/standards/templates/template.jsx b/src/pages/tenant/standards/templates/template.jsx index 3890212fdbd8..7e442863b3f1 100644 --- a/src/pages/tenant/standards/templates/template.jsx +++ b/src/pages/tenant/standards/templates/template.jsx @@ -1,205 +1,205 @@ -import { Box, Button, Container, Stack, Typography, SvgIcon, Skeleton } from "@mui/material"; -import { Grid } from "@mui/system"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import { useForm, useWatch } from "react-hook-form"; -import { useRouter } from "next/router"; -import { Add, SaveRounded } from "@mui/icons-material"; -import { useEffect, useState, useCallback, useMemo, useRef, lazy, Suspense } from "react"; -import standards from "../../../../data/standards"; -import CippStandardAccordion from "../../../../components/CippStandards/CippStandardAccordion"; +import { Box, Button, Container, Stack, Typography, SvgIcon, Skeleton } from '@mui/material' +import { Grid } from '@mui/system' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { useForm, useWatch } from 'react-hook-form' +import { useRouter } from 'next/router' +import { Add, SaveRounded } from '@mui/icons-material' +import { useEffect, useState, useCallback, useMemo, useRef, lazy, Suspense } from 'react' +import standards from '../../../../data/standards' +import CippStandardAccordion from '../../../../components/CippStandards/CippStandardAccordion' // Lazy load the dialog to improve initial page load performance const CippStandardDialog = lazy( - () => import("../../../../components/CippStandards/CippStandardDialog"), -); -import CippStandardsSideBar from "../../../../components/CippStandards/CippStandardsSideBar"; -import { ArrowLeftIcon } from "@mui/x-date-pickers"; -import { useDialog } from "../../../../hooks/use-dialog"; -import { ApiGetCall } from "../../../../api/ApiCall"; -import _ from "lodash"; -import { createDriftManagementActions } from "../../manage/driftManagementActions"; -import { ActionsMenu } from "../../../../components/actions-menu"; -import { useSettings } from "../../../../hooks/use-settings"; -import { CippHead } from "../../../../components/CippComponents/CippHead"; + () => import('../../../../components/CippStandards/CippStandardDialog') +) +import CippStandardsSideBar from '../../../../components/CippStandards/CippStandardsSideBar' +import { ArrowLeftIcon } from '@mui/x-date-pickers' +import { useDialog } from '../../../../hooks/use-dialog' +import { ApiGetCall } from '../../../../api/ApiCall' +import { get } from 'lodash' +import { createDriftManagementActions } from '../../manage/driftManagementActions' +import { ActionsMenu } from '../../../../components/actions-menu' +import { useSettings } from '../../../../hooks/use-settings' +import { CippHead } from '../../../../components/CippComponents/CippHead' const Page = () => { - const router = useRouter(); - const [editMode, setEditMode] = useState(false); - const formControl = useForm({ mode: "onBlur" }); - const { formState } = formControl; - const [dialogOpen, setDialogOpen] = useState(false); - const [expanded, setExpanded] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [selectedStandards, setSelectedStandards] = useState({}); - const [updatedAt, setUpdatedAt] = useState(false); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [currentStep, setCurrentStep] = useState(0); - const [hasDriftConflict, setHasDriftConflict] = useState(false); - const initialStandardsRef = useRef({}); - - const currentTenant = useSettings().currentTenant; + const router = useRouter() + const [editMode, setEditMode] = useState(false) + const formControl = useForm({ mode: 'onBlur' }) + const { formState } = formControl + const [dialogOpen, setDialogOpen] = useState(false) + const [expanded, setExpanded] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [selectedStandards, setSelectedStandards] = useState({}) + const [updatedAt, setUpdatedAt] = useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [currentStep, setCurrentStep] = useState(0) + const [hasDriftConflict, setHasDriftConflict] = useState(false) + const initialStandardsRef = useRef({}) + + const currentTenant = useSettings().currentTenant // Check if this is drift mode - const isDriftMode = router.query.type === "drift"; + const isDriftMode = router.query.type === 'drift' // Set drift mode flag in form when in drift mode useEffect(() => { if (isDriftMode) { - formControl.setValue("isDriftTemplate", true); + formControl.setValue('isDriftTemplate', true) } - }, [isDriftMode, formControl]); + }, [isDriftMode, formControl]) // Watch form values to check valid configuration - const watchForm = useWatch({ control: formControl.control }); + const watchForm = useWatch({ control: formControl.control }) const existingTemplate = ApiGetCall({ url: `/api/listStandardTemplates`, data: { id: router.query.id }, queryKey: `listStandardTemplates-${router.query.id}`, waiting: editMode, - }); + }) // Check if the template configuration is valid and update currentStep useEffect(() => { const stepsStatus = { - step1: !!_.get(watchForm, "templateName"), - step2: _.get(watchForm, "tenantFilter", []).length > 0, + step1: !!get(watchForm, 'templateName'), + step2: get(watchForm, 'tenantFilter', []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - _.get(watchForm, "standards") && + get(watchForm, 'standards') && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, standardName, {}); + const standardValues = get(watchForm, standardName, {}) // Always require an action value which should be an array with at least one element - const actionValue = _.get(standardValues, "action"); - return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); + const actionValue = get(standardValues, 'action') + return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0) }), - }; + } - const completedSteps = Object.values(stepsStatus).filter(Boolean).length; - setCurrentStep(completedSteps); - }, [selectedStandards, watchForm, isDriftMode]); + const completedSteps = Object.values(stepsStatus).filter(Boolean).length + setCurrentStep(completedSteps) + }, [selectedStandards, watchForm, isDriftMode]) // Handle route change events const handleRouteChange = useCallback( (url) => { if (hasUnsavedChanges) { const confirmLeave = window.confirm( - "You have unsaved changes. Are you sure you want to leave this page?", - ); + 'You have unsaved changes. Are you sure you want to leave this page?' + ) if (!confirmLeave) { - router.events.emit("routeChangeError"); - throw "Route change was aborted"; + router.events.emit('routeChangeError') + throw 'Route change was aborted' } } }, - [hasUnsavedChanges, router], - ); + [hasUnsavedChanges, router] + ) // Handle browser back/forward navigation or tab close useEffect(() => { const handleBeforeUnload = (e) => { if (hasUnsavedChanges) { - e.preventDefault(); - e.returnValue = "You have unsaved changes. Are you sure you want to leave this page?"; - return e.returnValue; + e.preventDefault() + e.returnValue = 'You have unsaved changes. Are you sure you want to leave this page?' + return e.returnValue } - }; + } // Add event listeners - window.addEventListener("beforeunload", handleBeforeUnload); - router.events.on("routeChangeStart", handleRouteChange); + window.addEventListener('beforeunload', handleBeforeUnload) + router.events.on('routeChangeStart', handleRouteChange) // Remove event listeners on cleanup return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - router.events.off("routeChangeStart", handleRouteChange); - }; - }, [hasUnsavedChanges, handleRouteChange, router.events]); + window.removeEventListener('beforeunload', handleBeforeUnload) + router.events.off('routeChangeStart', handleRouteChange) + } + }, [hasUnsavedChanges, handleRouteChange, router.events]) // Track form changes useEffect(() => { // Compare the current form values with the initial values to check for real changes - const currentValues = formControl.getValues(); - const initialValues = initialStandardsRef.current; + const currentValues = formControl.getValues() + const initialValues = initialStandardsRef.current if ( formState.isDirty || JSON.stringify(selectedStandards) !== JSON.stringify(initialStandardsRef.current) ) { - setHasUnsavedChanges(true); + setHasUnsavedChanges(true) } else { - setHasUnsavedChanges(false); + setHasUnsavedChanges(false) } - }, [formState.isDirty, selectedStandards, formControl]); + }, [formState.isDirty, selectedStandards, formControl]) useEffect(() => { if (router.query.id) { - setEditMode(true); + setEditMode(true) } if (existingTemplate.isSuccess) { //formControl.reset(existingTemplate.data?.[0]); - const apiData = existingTemplate.data?.[0]; + const apiData = existingTemplate.data?.[0] Object.keys(apiData.standards).forEach((key) => { if (Array.isArray(apiData.standards[key])) { apiData.standards[key] = apiData.standards[key].filter( - (value) => value !== null && value !== undefined, - ); + (value) => value !== null && value !== undefined + ) } - }); + }) - formControl.reset(apiData); + formControl.reset(apiData) if (router.query.clone) { - formControl.setValue("templateName", `${apiData.templateName} (Clone)`); - formControl.setValue("GUID", ""); + formControl.setValue('templateName', `${apiData.templateName} (Clone)`) + formControl.setValue('GUID', '') } //set the updated at date and user setUpdatedAt({ date: apiData?.updatedAt, user: apiData?.updatedBy, - }); + }) // Transform standards from the API to match the format for selectedStandards - const standardsFromApi = apiData?.standards; - const transformedStandards = {}; + const standardsFromApi = apiData?.standards + const transformedStandards = {} Object.keys(standardsFromApi).forEach((key) => { if (Array.isArray(standardsFromApi[key])) { standardsFromApi[key].forEach((_, index) => { - transformedStandards[`standards.${key}[${index}]`] = true; - }); + transformedStandards[`standards.${key}[${index}]`] = true + }) } else { - transformedStandards[`standards.${key}`] = true; + transformedStandards[`standards.${key}`] = true } - }); + }) - setSelectedStandards(transformedStandards); + setSelectedStandards(transformedStandards) // Store initial state for change detection - initialStandardsRef.current = { ...transformedStandards }; - setHasUnsavedChanges(false); + initialStandardsRef.current = { ...transformedStandards } + setHasUnsavedChanges(false) } - }, [existingTemplate.isSuccess, router]); + }, [existingTemplate.isSuccess, router]) // Memoize categories to avoid unnecessary recalculations const categories = useMemo(() => { return standards.reduce((acc, standard) => { - const { cat } = standard; + const { cat } = standard if (!acc[cat]) { - acc[cat] = []; + acc[cat] = [] } - acc[cat].push(standard); - return acc; - }, {}); - }, []); + acc[cat].push(standard) + return acc + }, {}) + }, []) const handleOpenDialog = useCallback(() => { - setDialogOpen(true); - }, []); + setDialogOpen(true) + }, []) const handleCloseDialog = useCallback(() => { - setDialogOpen(false); - setSearchQuery(""); - }, []); + setDialogOpen(false) + setSearchQuery('') + }, []) const filterStandards = (standardsList) => standardsList.filter( @@ -211,149 +211,157 @@ const Page = () => { (standard.appliesToTest && standard.appliesToTest.some((testId) => testId.toLowerCase().includes(searchQuery.toLowerCase()) - )), - ); + )) + ) const handleToggleStandard = (standardName) => { setSelectedStandards((prev) => ({ ...prev, [standardName]: !prev[standardName], - })); - }; + })) + } const handleAddMultipleStandard = (standardName) => { //if the standardname contains an array qualifier,e.g standardName[0], strip that away. - const arrayPattern = /(.*)\[(\d+)\]$/; - const match = standardName.match(arrayPattern); + const arrayPattern = /(.*)\[(\d+)\]$/ + const match = standardName.match(arrayPattern) if (match) { - standardName = match[1]; + standardName = match[1] } setSelectedStandards((prev) => { - const existingInstances = Object.keys(prev).filter((name) => name.startsWith(standardName)); - const newIndex = existingInstances.length; + const existingInstances = Object.keys(prev).filter((name) => name.startsWith(standardName)) + const newIndex = existingInstances.length return { ...prev, [`${standardName}[${newIndex}]`]: true, - }; - }); - }; + } + }) + } const handleRemoveStandard = (standardName) => { - const arrayPattern = /(.*)\[(\d+)\]$/; - const match = standardName.match(arrayPattern); + const arrayPattern = /(.*)\[(\d+)\]$/ + const match = standardName.match(arrayPattern) if (match) { - const baseName = match[1]; - const removedIndex = parseInt(match[2]); + const baseName = match[1] + const removedIndex = parseInt(match[2]) // Remove the item from the form array - const currentArray = formControl.getValues(baseName) || []; - const updatedArray = currentArray.filter((_, i) => i !== removedIndex); - formControl.setValue(baseName, updatedArray); + const currentArray = formControl.getValues(baseName) || [] + const updatedArray = currentArray.filter((_, i) => i !== removedIndex) + formControl.setValue(baseName, updatedArray) // Re-index selectedStandards to keep indices contiguous - const escapedBaseName = baseName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const reindexPattern = new RegExp(`^${escapedBaseName}\\[(\\d+)\\]$`); + const escapedBaseName = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const reindexPattern = new RegExp(`^${escapedBaseName}\\[(\\d+)\\]$`) setSelectedStandards((prev) => { - const newSelected = {}; + const newSelected = {} Object.keys(prev).forEach((key) => { - const keyMatch = key.match(reindexPattern); + const keyMatch = key.match(reindexPattern) if (keyMatch) { - const idx = parseInt(keyMatch[1]); + const idx = parseInt(keyMatch[1]) if (idx < removedIndex) { - newSelected[key] = prev[key]; + newSelected[key] = prev[key] } else if (idx > removedIndex) { // Shift higher indices down by 1 - newSelected[`${baseName}[${idx - 1}]`] = prev[key]; + newSelected[`${baseName}[${idx - 1}]`] = prev[key] } // Skip the removed index } else { - newSelected[key] = prev[key]; + newSelected[key] = prev[key] } - }); - return newSelected; - }); + }) + return newSelected + }) } else { setSelectedStandards((prev) => { - const newSelected = { ...prev }; - delete newSelected[standardName]; - return newSelected; - }); - formControl.unregister(standardName); + const newSelected = { ...prev } + delete newSelected[standardName] + return newSelected + }) + formControl.unregister(standardName) } - }; + } const handleAccordionToggle = (standardName) => { - setExpanded((prev) => (prev === standardName ? null : standardName)); - }; + setExpanded((prev) => (prev === standardName ? null : standardName)) + } - const createDialog = useDialog(); + const createDialog = useDialog() // Save action that will open the create dialog const handleSave = () => { - createDialog.handleOpen(); + createDialog.handleOpen() // Will be set to false after successful save in the dialog component - }; + } // Determine if save button should be disabled based on configuration const isSaveDisabled = isDriftMode - ? !_.get(watchForm, "tenantFilter") || - !_.get(watchForm, "tenantFilter").length || + ? !get(watchForm, 'tenantFilter') || + !get(watchForm, 'tenantFilter').length || currentStep < 4 || hasDriftConflict // For drift mode, require all steps and no drift conflicts - : !_.get(watchForm, "tenantFilter") || - !_.get(watchForm, "tenantFilter").length || - currentStep < 4; + : !get(watchForm, 'tenantFilter') || + !get(watchForm, 'tenantFilter').length || + currentStep < 4 // Create drift management actions (excluding refresh) const driftActions = useMemo(() => { - if (!editMode || !router.query.id) return []; + if (!editMode || !router.query.id) return [] const allActions = createDriftManagementActions({ templateId: router.query.id, - onRefresh: () => {}, // Empty function since we're filtering out refresh + onRefresh: () => {}, currentTenant: currentTenant, - }); + templateTenants: Array.isArray(watchForm?.tenantFilter) ? watchForm.tenantFilter : [], + excludedTenants: Array.isArray(watchForm?.excludedTenants) ? watchForm.excludedTenants : [], + }) // Filter out the refresh action - return allActions.filter((action) => action.label !== "Refresh Data"); - }, [editMode, router.query.id, currentTenant]); + return allActions.filter((action) => action.label !== 'Refresh Data') + }, [ + editMode, + router.query.id, + currentTenant, + watchForm?.tenantFilter, + watchForm?.excludedTenants, + ]) - const actions = []; + const actions = [] const steps = [ - "Set a name for the Template", - "Assigned Template to Tenants", - "Added Standards to Template", - "Configured all Standards", - ]; + 'Set a name for the Template', + 'Assigned Template to Tenants', + 'Added Standards to Template', + 'Configured all Standards', + ] const handleSafeNavigation = (url) => { if (hasUnsavedChanges) { const confirmLeave = window.confirm( - "You have unsaved changes. Are you sure you want to leave this page?", - ); + 'You have unsaved changes. Are you sure you want to leave this page?' + ) if (confirmLeave) { - router.push(url); + router.push(url) } } else { - router.push(url); + router.push(url) } - }; + } return ( - + @@ -368,11 +376,11 @@ const Page = () => { {editMode ? isDriftMode - ? "Edit Drift Template" - : "Edit Standards Template" + ? 'Edit Drift Template' + : 'Edit Standards Template' : isDriftMode - ? "Add Drift Template" - : "Add Standards Template"} + ? 'Add Drift Template' + : 'Add Standards Template'}