From 51a82e82684e9d6638eb8f029566d13fafc338da Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:59:30 +0100 Subject: [PATCH 01/17] updated text guidiance --- src/components/CippWizard/CippSAMDeploy.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/CippWizard/CippSAMDeploy.jsx b/src/components/CippWizard/CippSAMDeploy.jsx index 2cb619fef7aa..d38d0f66ddf2 100644 --- a/src/components/CippWizard/CippSAMDeploy.jsx +++ b/src/components/CippWizard/CippSAMDeploy.jsx @@ -92,7 +92,10 @@ export const CippSAMDeploy = (props) => { here -
  • (Temporary) Global Administrator permissions for the CIPP Service Account
  • +
  • + An account with at minimum:
  • Application Administrator
  • +
  • User Administrator
  • +
  • Multi-factor authentication enabled for the CIPP Service Account, with no trusted locations or other exclusions. From f3e3b47b9e864ff0f9e30858d71b6b60bcf36043 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:52:38 +0100 Subject: [PATCH 02/17] Updated drift management --- src/pages/tenant/manage/drift.js | 409 +++++++++++++++++++++++++++++-- 1 file changed, 387 insertions(+), 22 deletions(-) diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js index 5e3d3756352d..4dea1b493dab 100644 --- a/src/pages/tenant/manage/drift.js +++ b/src/pages/tenant/manage/drift.js @@ -205,8 +205,10 @@ const ManageDriftPage = () => { ComplianceStatus: detail.ComplianceStatus, StandardValue: detail.StandardValue, ReportingDisabled: detail.ReportingDisabled, - expectedValue: "Compliant with template", - receivedValue: detail.StandardValue, + ExpectedValue: detail.ExpectedValue, + CurrentValue: detail.CurrentValue, + expectedValue: detail.ExpectedValue || "Compliant with template", + receivedValue: detail.CurrentValue || detail.StandardValue, }; }) .filter((item) => item !== null); // Filter out null items where templates weren't found @@ -376,6 +378,45 @@ const ManageDriftPage = () => { } }; + // Helper function to format differences for display + const formatDifferences = (differences) => { + if (!differences || typeof differences !== 'object') return null; + + const formatted = []; + Object.entries(differences).forEach(([key, value]) => { + formatted.push({ + property: key, + expected: value.expected !== undefined ? JSON.stringify(value.expected, null, 2) : 'Not set', + current: value.current !== undefined ? JSON.stringify(value.current, null, 2) : 'Not set', + }); + }); + + return formatted; + }; + + // Helper function to format matching properties for compliant items + const formatCompliantProperties = (value) => { + if (!value) return null; + + try { + const obj = typeof value === "string" ? JSON.parse(value) : value; + + if (typeof obj !== 'object' || obj === null) return null; + + const formatted = []; + Object.entries(obj).forEach(([key, val]) => { + formatted.push({ + property: key, + value: val !== undefined ? JSON.stringify(val, null, 2) : 'Not set', + }); + }); + + return formatted.length > 0 ? formatted : null; + } catch (e) { + return null; + } + }; + // Helper function to format policy objects for display const formatPolicyValue = (value) => { if (!value) return "N/A"; @@ -466,10 +507,15 @@ const ManageDriftPage = () => { let displayExpectedValue = deviation.ExpectedValue || deviation.expectedValue; let displayReceivedValue = deviation.CurrentValue || deviation.receivedValue; - // If we have JSON differences, show only the differences + // If we have JSON differences, format them for display + let formattedDifferences = null; + let formattedCompliantProps = null; + if (jsonDifferences && !isLicenseSkipped && !isActuallyCompliant) { - displayExpectedValue = JSON.stringify(jsonDifferences, null, 2); - displayReceivedValue = "See differences in Expected column"; + formattedDifferences = formatDifferences(jsonDifferences); + } else if ((isActuallyCompliant || actualStatus === "aligned") && displayExpectedValue) { + // For compliant items, format the properties to show them matching + formattedCompliantProps = formatCompliantProperties(displayExpectedValue); } return { @@ -512,10 +558,285 @@ const ManageDriftPage = () => { )} - {(displayExpectedValue && displayExpectedValue !== "Compliant with template") || - displayReceivedValue ? ( - - {displayExpectedValue && displayExpectedValue !== "Compliant with template" && ( + {formattedDifferences && formattedDifferences.length > 0 ? ( + + + Property Differences + + {formattedDifferences.map((diff, idx) => ( + + + {diff.property} + + + + + Expected + + + + + + + {diff.expected} + + + + + + Current + + + + + + + {diff.current} + + + + + + ))} + + ) : formattedCompliantProps && formattedCompliantProps.length > 0 ? ( + + + Compliant Properties + + {formattedCompliantProps.map((prop, idx) => ( + + + {prop.property} + + + + + Expected + + + + + + + {prop.value} + + + + + + Current + + + + + + + {prop.value} + + + + + + ))} + + ) : displayExpectedValue || displayReceivedValue ? ( + + {displayExpectedValue && ( { color: "text.secondary", textTransform: "uppercase", letterSpacing: 0.5, + display: "block", + mb: 0.5, }} > - {jsonDifferences ? "Differences" : "Expected"} + Expected + {(isActuallyCompliant || actualStatus === "aligned") && ( + + + + )} { fontSize: "0.8125rem", whiteSpace: "pre-wrap", wordBreak: "break-word", + color: isActuallyCompliant || actualStatus === "aligned" ? "success.dark" : "text.primary", }} > - {displayExpectedValue} + {displayExpectedValue === "Compliant with template" + ? displayReceivedValue || "Compliant" + : displayExpectedValue} )} - {displayReceivedValue && !jsonDifferences && ( + {displayReceivedValue && ( { color: "text.secondary", textTransform: "uppercase", letterSpacing: 0.5, + display: "block", + mb: 0.5, }} > Current + {(isActuallyCompliant || actualStatus === "aligned") && ( + + + + )} { fontSize: "0.8125rem", whiteSpace: "pre-wrap", wordBreak: "break-word", + color: isActuallyCompliant || actualStatus === "aligned" ? "success.dark" : "text.primary", }} > {displayReceivedValue} From 2c5ab0d167c57631a095b93e79e57e2c5844f158 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:52:46 +0100 Subject: [PATCH 03/17] Update compliant drift monitoring --- src/pages/tenant/manage/drift.js | 123 +++++++++++++++++++------------ 1 file changed, 77 insertions(+), 46 deletions(-) diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js index 4dea1b493dab..0b3c226bc693 100644 --- a/src/pages/tenant/manage/drift.js +++ b/src/pages/tenant/manage/drift.js @@ -130,7 +130,7 @@ const ManageDriftPage = () => { } if (item.customerSpecificDeviations && Array.isArray(item.customerSpecificDeviations)) { acc.customerSpecificDeviationsList.push( - ...item.customerSpecificDeviations.filter((dev) => dev !== null) + ...item.customerSpecificDeviations.filter((dev) => dev !== null), ); } if (item.deniedDeviations && Array.isArray(item.deniedDeviations)) { @@ -143,7 +143,7 @@ const ManageDriftPage = () => { Array.isArray(item.driftSettings.ComparisonDetails) ) { const compliantStandards = item.driftSettings.ComparisonDetails.filter( - (detail) => detail.Compliant === true + (detail) => detail.Compliant === true, ) .map((detail) => { // Strip "standards." prefix if present @@ -238,7 +238,7 @@ const ManageDriftPage = () => { deniedDeviationsList: [], alignedStandards: [], latestDataCollection: null, - } + }, ); // Transform currentDeviations into deviation items for display @@ -380,14 +380,15 @@ const ManageDriftPage = () => { // Helper function to format differences for display const formatDifferences = (differences) => { - if (!differences || typeof differences !== 'object') return null; + if (!differences || typeof differences !== "object") return null; const formatted = []; Object.entries(differences).forEach(([key, value]) => { formatted.push({ property: key, - expected: value.expected !== undefined ? JSON.stringify(value.expected, null, 2) : 'Not set', - current: value.current !== undefined ? JSON.stringify(value.current, null, 2) : 'Not set', + expected: + value.expected !== undefined ? JSON.stringify(value.expected, null, 2) : "Not set", + current: value.current !== undefined ? JSON.stringify(value.current, null, 2) : "Not set", }); }); @@ -400,14 +401,14 @@ const ManageDriftPage = () => { try { const obj = typeof value === "string" ? JSON.parse(value) : value; - - if (typeof obj !== 'object' || obj === null) return null; + + if (typeof obj !== "object" || obj === null) return null; const formatted = []; Object.entries(obj).forEach(([key, val]) => { formatted.push({ property: key, - value: val !== undefined ? JSON.stringify(val, null, 2) : 'Not set', + value: val !== undefined ? JSON.stringify(val, null, 2) : "Not set", }); }); @@ -495,13 +496,13 @@ const ManageDriftPage = () => { const actualStatus = isActuallyCompliant ? "aligned" : isLicenseSkipped - ? "skipped" - : statusOverride || deviation.Status || deviation.state; + ? "skipped" + : statusOverride || deviation.Status || deviation.state; const actualStatusText = isActuallyCompliant ? "Compliant" : isLicenseSkipped - ? "Skipped - No License Available" - : getDeviationStatusText(actualStatus); + ? "Skipped - No License Available" + : getDeviationStatusText(actualStatus); // For skipped items, show different expected/received values let displayExpectedValue = deviation.ExpectedValue || deviation.expectedValue; @@ -510,7 +511,7 @@ const ManageDriftPage = () => { // If we have JSON differences, format them for display let formattedDifferences = null; let formattedCompliantProps = null; - + if (jsonDifferences && !isLicenseSkipped && !isActuallyCompliant) { formattedDifferences = formatDifferences(jsonDifferences); } else if ((isActuallyCompliant || actualStatus === "aligned") && displayExpectedValue) { @@ -583,7 +584,9 @@ const ManageDriftPage = () => { > {diff.property} - + { > {prop.property} - + { @@ -886,11 +901,14 @@ const ManageDriftPage = () => { fontSize: "0.8125rem", whiteSpace: "pre-wrap", wordBreak: "break-word", - color: isActuallyCompliant || actualStatus === "aligned" ? "success.dark" : "text.primary", + color: + isActuallyCompliant || actualStatus === "aligned" + ? "success.dark" + : "text.primary", }} > - {displayExpectedValue === "Compliant with template" - ? displayReceivedValue || "Compliant" + {displayExpectedValue === "Compliant with template" + ? displayReceivedValue || "Compliant" : displayExpectedValue} @@ -915,10 +933,20 @@ const ManageDriftPage = () => { @@ -947,7 +975,10 @@ const ManageDriftPage = () => { fontSize: "0.8125rem", whiteSpace: "pre-wrap", wordBreak: "break-word", - color: isActuallyCompliant || actualStatus === "aligned" ? "success.dark" : "text.primary", + color: + isActuallyCompliant || actualStatus === "aligned" + ? "success.dark" + : "text.primary", }} > {displayReceivedValue} @@ -1011,15 +1042,15 @@ const ManageDriftPage = () => { const deviationItems = createDeviationItems(processedDriftData.currentDeviations); const acceptedDeviationItems = createDeviationItems( processedDriftData.acceptedDeviations, - "accepted" + "accepted", ); const customerSpecificDeviationItems = createDeviationItems( processedDriftData.customerSpecificDeviationsList, - "customerspecific" + "customerspecific", ); const deniedDeviationItems = createDeviationItems( processedDriftData.deniedDeviationsList, - "denied" + "denied", ); const alignedStandardItems = createDeviationItems(processedDriftData.alignedStandards, "aligned"); @@ -1027,7 +1058,7 @@ const ManageDriftPage = () => { const licenseSkippedItems = deviationItems.filter((item) => item.isLicenseSkipped); const compliantFromDeviations = deviationItems.filter((item) => item.isActuallyCompliant); const actualDeviationItems = deviationItems.filter( - (item) => !item.isLicenseSkipped && !item.isActuallyCompliant + (item) => !item.isLicenseSkipped && !item.isActuallyCompliant, ); // Combine compliant items from both sources @@ -1249,7 +1280,7 @@ const ManageDriftPage = () => { // Find current tenant data const currentTenantData = currentTenantInfo.data?.find( - (tenant) => tenant.defaultDomainName === tenantFilter + (tenant) => tenant.defaultDomainName === tenantFilter, ); // Actions for the ActionsMenu @@ -1412,7 +1443,7 @@ const ManageDriftPage = () => { (item) => item.text?.toLowerCase().includes(searchQuery.toLowerCase()) || item.subtext?.toLowerCase().includes(searchQuery.toLowerCase()) || - item.standardName?.toLowerCase().includes(searchQuery.toLowerCase()) + item.standardName?.toLowerCase().includes(searchQuery.toLowerCase()), ); } @@ -1650,10 +1681,10 @@ const ManageDriftPage = () => { combinedScore === 100 ? "success" : combinedScore >= 80 - ? "warning" - : combinedScore >= 30 - ? "warning" - : "error" + ? "warning" + : combinedScore >= 30 + ? "warning" + : "error" } variant="outlined" /> @@ -1685,7 +1716,7 @@ const ManageDriftPage = () => { query: query, }, undefined, - { shallow: true } + { shallow: true }, ); }} placeholder="Select a drift template..." @@ -1729,8 +1760,8 @@ const ManageDriftPage = () => { sortBy === "name" ? "Name" : sortBy === "status" - ? "Status" - : "Category", + ? "Status" + : "Category", value: sortBy, } : null @@ -1791,7 +1822,7 @@ const ManageDriftPage = () => { (deviation.standardName?.includes("ConditionalAccessTemplate") || deviation.standardName?.includes("IntuneTemplate")) && deviation.expectedValue === - "This policy only exists in the tenant, not in the template." + "This policy only exists in the tenant, not in the template.", ) && ( handleBulkAction("deny-all-delete")}> @@ -1913,10 +1944,10 @@ const ManageDriftPage = () => { actionData.action?.type === "single" ? "this deviation" : actionData.action?.type === "bulk" - ? `these ${actionData.action?.count || 0} deviations` - : actionData.action?.type === "reset" - ? "for this tenant" - : "this deviation" + ? `these ${actionData.action?.count || 0} deviations` + : actionData.action?.type === "reset" + ? "for this tenant" + : "this deviation" }?`, }} row={actionData.data} From 438b5a9fc845d860ce1c1893e7fa64cc399cd1b1 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:52:28 +0800 Subject: [PATCH 04/17] Update CippAddTestReportDrawer.jsx --- src/components/CippComponents/CippAddTestReportDrawer.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/CippComponents/CippAddTestReportDrawer.jsx b/src/components/CippComponents/CippAddTestReportDrawer.jsx index d7ee1646656f..6ea7d84ea0bb 100644 --- a/src/components/CippComponents/CippAddTestReportDrawer.jsx +++ b/src/components/CippComponents/CippAddTestReportDrawer.jsx @@ -43,13 +43,13 @@ export const CippAddTestReportDrawer = ({ buttonText = "Create custom report" }) const createReport = ApiPostCall({ urlFromData: true, - relatedQueryKeys: ["ListTestReports"], + relatedQueryKeys: "ListTestReports", }); // Fetch available tests for the form const availableTestsApi = ApiGetCall({ url: "/api/ListAvailableTests", - queryKey: ["ListAvailableTests"], + queryKey: "ListAvailableTests", }); const availableTests = availableTestsApi.data || { IdentityTests: [], DevicesTests: [] }; From 26d9e09ea97e0caa555fcc588267c24b99ffa97b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:10:03 +0800 Subject: [PATCH 05/17] securescore alert --- src/data/alerts.json | 34 +++++++++++++++++++ .../alert-configuration/alert.jsx | 20 +++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/data/alerts.json b/src/data/alerts.json index dd6d8b6cc065..22a8f06ca072 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -331,5 +331,39 @@ "label": "Alert on quarantine release requests", "recommendedRunInterval": "30m", "description": "Monitors for user requests to release quarantined messages and provides a CIPP-native alternative to the external email forwarding method. This helps MSPs maintain secure configurations while getting timely notifications about quarantine activity. Links to the tenant's quarantine page are provided in alerts." + }, + { + "name": "SecureScore", + "label": "Alert on a low Secure Score", + "recommendedRunInterval": "1d", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "autoComplete", + "inputLabel": "Threshold type absolute number or percent", + "inputName": "ThresholdType", + "creatable": false, + "multiple": false, + "options": [ + { + "label": "Percent", + "value": "percent" + }, + { + "label": "Absolute", + "value": "absolute" + } + ], + "required": true + }, + { + "inputType": "number", + "inputLabel": "Threshold Value (below this will trigger the alert)", + "inputName": "InputValue", + "required": true + } + ], + "description": "Monitors Secure Score and alerts when it falls below the specified threshold (absolute or percent value). Helps identify security gaps and areas for improvement." } ] diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index 91d671113f6b..a156d6086093 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -958,6 +958,16 @@ const AlertWizard = () => { name={commandValue.value?.inputName} formControl={formControl} label={commandValue.value?.inputLabel} + required={commandValue.value?.required || false} + {...(commandValue.value?.inputType === 'autoComplete' + ? { + options: commandValue.value?.options, + creatable: commandValue.value?.creatable || true, + multiple: commandValue.value?.multiple || true, + } + : {} + ) + } /> )} {commandValue?.value?.multipleInput && @@ -974,6 +984,16 @@ const AlertWizard = () => { name={input.inputName} formControl={formControl} label={input.inputLabel} + required={input.required || false} + {...(input.inputType === 'autoComplete' + ? { + options: input.options, + creatable: input.creatable ?? true, + multiple: input.multiple ?? true, + } + : {} + ) + } /> From 8c99a916f1297bd7bd6b18a15bcc57426bb7b4ac Mon Sep 17 00:00:00 2001 From: Luke Steward <29278153+LukeSteward@users.noreply.github.com> Date: Mon, 19 Jan 2026 13:25:14 +0000 Subject: [PATCH 06/17] Fixed failed labeling workflow Workflow was missing token as part of the required usage. https://github.com/JasonEtco/is-sponsor-label-action?tab=readme-ov-file#usage Reusing existing token value inside of the repository as referenced by other workflows Changed the label to match the one on GitHub Signed-off-by: Luke Steward <29278153+LukeSteward@users.noreply.github.com> --- .github/workflows/label_sponsor_requests.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/label_sponsor_requests.yml b/.github/workflows/label_sponsor_requests.yml index 479cad06c728..b974cf66776f 100644 --- a/.github/workflows/label_sponsor_requests.yml +++ b/.github/workflows/label_sponsor_requests.yml @@ -1,4 +1,3 @@ ---- name: Label Issues on: issues: @@ -14,4 +13,6 @@ jobs: - name: Sponsor Labels uses: JasonEtco/is-sponsor-label-action@v1.2.0 with: - label: 'Sponsor Request' + label: 'Sponsor Priority' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From bf8e9126ecca961985b25c41c5daae8f4912419e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 19 Jan 2026 09:43:21 -0500 Subject: [PATCH 07/17] Expand permissions for Mailbox Permissions menu Updated the permissions for the 'Mailbox Permissions' menu item from 'Exchange.Mailbox.Read' to 'Exchange.Mailbox.*' to allow broader access. This change enables users with any Exchange mailbox permission to access the menu. --- src/layouts/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/config.js b/src/layouts/config.js index d735b015c94c..8e8fd1f97c39 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -719,7 +719,7 @@ export const nativeMenuItems = [ { title: "Mailbox Permissions", path: "/email/reports/mailbox-permissions", - permissions: ["Exchange.Mailbox.Read"], + permissions: ["Exchange.Mailbox.*"], }, { title: "Anti-Phishing Filters", From 02b4ce54d37dd9b0372d893bdefd36d4a53558ee Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 20 Jan 2026 11:04:54 -0500 Subject: [PATCH 08/17] Pass dateFilter to log entry view and API call Updated the log entry view action to include the dateFilter parameter in the URL. Modified logentry.js to extract dateFilter from the query and pass it to the API call for fetching log details. Also improved code formatting for severity badge rendering. --- src/pages/cipp/logs/index.js | 12 ++++++------ src/pages/cipp/logs/logentry.js | 15 ++++++++------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/pages/cipp/logs/index.js b/src/pages/cipp/logs/index.js index 18f8ad68174c..41eac2225d46 100644 --- a/src/pages/cipp/logs/index.js +++ b/src/pages/cipp/logs/index.js @@ -37,7 +37,7 @@ const pageTitle = "Logbook Results"; const actions = [ { label: "View Log Entry", - link: "/cipp/logs/logentry?logentry=[RowKey]", + link: "/cipp/logs/logentry?logentry=[RowKey]&dateFilter=[DateFilter]", icon: , color: "primary", }, @@ -100,14 +100,14 @@ const Page = () => { setStartDate( data.startDate ? new Date(data.startDate * 1000).toISOString().split("T")[0].replace(/-/g, "") - : null + : null, ); // Format end date if available setEndDate( data.endDate ? new Date(data.endDate * 1000).toISOString().split("T")[0].replace(/-/g, "") - : null + : null, ); // Set username filter if available @@ -117,7 +117,7 @@ const Page = () => { setSeverity( data.severity && data.severity.length > 0 ? data.severity.map((item) => item.value).join(",") - : null + : null, ); // Close the accordion after applying filters @@ -157,13 +157,13 @@ const Page = () => { <> {startDate ? new Date( - startDate.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3") + "T00:00:00" + startDate.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3") + "T00:00:00", ).toLocaleDateString() : new Date().toLocaleDateString()} {startDate && endDate ? " - " : ""} {endDate ? new Date( - endDate.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3") + "T00:00:00" + endDate.replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3") + "T00:00:00", ).toLocaleDateString() : ""} diff --git a/src/pages/cipp/logs/logentry.js b/src/pages/cipp/logs/logentry.js index 2dbb4a23a5a7..175dcc915aba 100644 --- a/src/pages/cipp/logs/logentry.js +++ b/src/pages/cipp/logs/logentry.js @@ -11,12 +11,13 @@ import { getCippTranslation } from "../../../utils/get-cipp-translation"; const Page = () => { const router = useRouter(); - const { logentry } = router.query; + const { logentry, dateFilter } = router.query; const logRequest = ApiGetCall({ url: `/api/Listlogs`, data: { logentryid: logentry, + dateFilter: dateFilter, }, queryKey: `GetLogEntry-${logentry}`, waiting: !!logentry, @@ -44,12 +45,12 @@ const Page = () => { logData.Severity === "CRITICAL" ? "error" : logData.Severity === "Error" - ? "error" - : logData.Severity === "Warn" - ? "warning" - : logData.Severity === "Info" - ? "info" - : "default" + ? "error" + : logData.Severity === "Warn" + ? "warning" + : logData.Severity === "Info" + ? "info" + : "default" } variant="filled" /> From c17dceae613ef8fb329b85ddf4aefaff248d65e3 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 20 Jan 2026 12:01:39 -0500 Subject: [PATCH 09/17] Improve error handling and retry logic in API calls Added status code 302 and 500 to the list of HTTP statuses that should not trigger retries in ApiGetCallWithPagination. When a 302 redirect to the AAD login is detected, the 'authmecipp' query is invalidated. Also fixed minor formatting and trailing comma issues in several functions. --- src/api/ApiCall.jsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/api/ApiCall.jsx b/src/api/ApiCall.jsx index e4d9a366908c..a2d7d0396ce1 100644 --- a/src/api/ApiCall.jsx +++ b/src/api/ApiCall.jsx @@ -50,7 +50,7 @@ export function ApiGetCall(props) { title: `${ error.config?.params?.tenantFilter ? error.config?.params?.tenantFilter : "" } Error`, - }) + }), ); } return returnRetry; @@ -211,7 +211,7 @@ export function ApiPostCall({ relatedQueryKeys, onResult }) { if (!query.queryKey || !query.queryKey[0]) return false; const queryKeyStr = String(query.queryKey[0]); const matches = wildcardPatterns.some((pattern) => - queryKeyStr.startsWith(pattern) + queryKeyStr.startsWith(pattern), ); // Debug logging for each query check @@ -220,7 +220,7 @@ export function ApiPostCall({ relatedQueryKeys, onResult }) { queryKey: query.queryKey, queryKeyStr, matchedPattern: wildcardPatterns.find((pattern) => - queryKeyStr.startsWith(pattern) + queryKeyStr.startsWith(pattern), ), }); } @@ -252,8 +252,9 @@ export function ApiGetCallWithPagination({ waiting = true, }) { const dispatch = useDispatch(); + const queryClient = useQueryClient(); const MAX_RETRIES = retry; - const HTTP_STATUS_TO_NOT_RETRY = [401, 403, 404]; + const HTTP_STATUS_TO_NOT_RETRY = [302, 401, 403, 404, 500]; const retryFn = (failureCount, error) => { let returnRetry = true; @@ -261,6 +262,12 @@ export function ApiGetCallWithPagination({ returnRetry = false; } if (isAxiosError(error) && HTTP_STATUS_TO_NOT_RETRY.includes(error.response?.status ?? 0)) { + if ( + error.response?.status === 302 && + error.response?.headers.get("location").includes("/.auth/login/aad") + ) { + queryClient.invalidateQueries({ queryKey: ["authmecipp"] }); + } returnRetry = false; } @@ -270,7 +277,7 @@ export function ApiGetCallWithPagination({ message: getCippError(error), title: "Error", toastError: error, - }) + }), ); } return returnRetry; From 8e0c76f20fdf38fc408f6114be85daee751ae10a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 20 Jan 2026 12:17:58 -0500 Subject: [PATCH 10/17] Add calendar permissions report page Introduces a new page for viewing calendar permissions, allowing grouping by user or calendar. Includes cache sync functionality and displays cached data from the reporting database, with support for multi-tenant views. --- src/layouts/config.js | 5 + .../reports/calendar-permissions/index.js | 117 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 src/pages/email/reports/calendar-permissions/index.js diff --git a/src/layouts/config.js b/src/layouts/config.js index 8e8fd1f97c39..82e68a157e9c 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -721,6 +721,11 @@ export const nativeMenuItems = [ path: "/email/reports/mailbox-permissions", permissions: ["Exchange.Mailbox.*"], }, + { + title: "Calendar Permissions", + path: "/email/reports/calendar-permissions", + permissions: ["Exchange.Mailbox.*"], + }, { title: "Anti-Phishing Filters", path: "/email/reports/antiphishing-filters", diff --git a/src/pages/email/reports/calendar-permissions/index.js b/src/pages/email/reports/calendar-permissions/index.js new file mode 100644 index 000000000000..d945e29b8c10 --- /dev/null +++ b/src/pages/email/reports/calendar-permissions/index.js @@ -0,0 +1,117 @@ +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { useState } from "react"; +import { + Button, + FormControlLabel, + Switch, + Alert, + SvgIcon, + IconButton, + Tooltip, +} from "@mui/material"; +import { useSettings } from "../../../../hooks/use-settings"; +import { Stack } from "@mui/system"; +import { Sync, Info } from "@mui/icons-material"; +import { useDialog } from "../../../../hooks/use-dialog"; +import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog"; + +const Page = () => { + const [byUser, setByUser] = useState(true); + const currentTenant = useSettings().currentTenant; + const syncDialog = useDialog(); + + const isAllTenants = currentTenant === "AllTenants"; + + const columns = byUser + ? [ + ...(isAllTenants ? ["Tenant"] : []), + "User", + "UserMailboxType", + "Permissions", + "MailboxCacheTimestamp", + "PermissionCacheTimestamp", + ] + : [ + ...(isAllTenants ? ["Tenant"] : []), + "CalendarUPN", + "CalendarDisplayName", + "CalendarType", + "Permissions", + "MailboxCacheTimestamp", + "PermissionCacheTimestamp", + ]; + + // Compute apiData based on byUser directly (no useState needed) + const apiData = { + UseReportDB: true, + ByUser: byUser, + }; + + const pageActions = [ + + + + + + + + setByUser(e.target.checked)} color="primary" /> + } + label="Group by User" + labelPlacement="start" + /> + , + ]; + + return ( + <> + {currentTenant && currentTenant !== "" ? ( + + ) : ( + Please select a tenant to view calendar permissions. + )} + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; From bcd57dc2b88c236a58b39d35620b218b3928ff0d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:40:04 +0100 Subject: [PATCH 11/17] Fixes drift issues --- src/pages/tenant/manage/applied-standards.js | 1007 +++++++++++++----- 1 file changed, 764 insertions(+), 243 deletions(-) diff --git a/src/pages/tenant/manage/applied-standards.js b/src/pages/tenant/manage/applied-standards.js index 5b6539767fce..ef11f5e33e34 100644 --- a/src/pages/tenant/manage/applied-standards.js +++ b/src/pages/tenant/manage/applied-standards.js @@ -35,6 +35,7 @@ import { NotificationImportant, Construction, Schedule, + Check, } from "@mui/icons-material"; import standards from "/src/data/standards.json"; import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; @@ -91,7 +92,7 @@ const Page = () => { // Selected template object (safe lookup) const selectedTemplate = useMemo( () => templates.find((t) => t.GUID === templateId), - [templates, templateId] + [templates, templateId], ); // Run the report once @@ -123,7 +124,7 @@ const Page = () => { useEffect(() => { if (templateId && templateDetails.isSuccess && templateDetails.data) { const selectedTemplate = templateDetails.data.find( - (template) => template.GUID === templateId + (template) => template.GUID === templateId, ); if (selectedTemplate && comparisonApi.isSuccess && comparisonApi.data) { @@ -157,30 +158,55 @@ const Page = () => { const itemTemplateId = expandedTemplate.GUID; const standardId = `standards.IntuneTemplate.${itemTemplateId}`; const standardInfo = standards.find( - (s) => s.name === `standards.IntuneTemplate` + (s) => s.name === `standards.IntuneTemplate`, ); // Find the tenant's value for this specific template const currentTenantStandard = currentTenantData.find( - (s) => s.standardId === standardId + (s) => s.standardId === standardId, ); // Get the standard object and its value from the tenant object const standardObject = currentTenantObj?.[standardId]; const directStandardValue = standardObject?.Value; - // Determine compliance status + // Determine compliance status - match main logic let isCompliant = false; - // For IntuneTemplate, the value is true if compliant, or an object with comparison data if not compliant - if (directStandardValue === true) { - isCompliant = true; - } else if ( - directStandardValue !== undefined && - typeof directStandardValue !== "object" + // FIRST: Check if CurrentValue and ExpectedValue exist and match + if ( + standardObject?.CurrentValue !== undefined && + standardObject?.ExpectedValue !== undefined ) { + const sortedCurrent = + typeof standardObject.CurrentValue === "object" && + standardObject.CurrentValue !== null + ? Object.keys(standardObject.CurrentValue) + .sort() + .reduce((obj, key) => { + obj[key] = standardObject.CurrentValue[key]; + return obj; + }, {}) + : standardObject.CurrentValue; + const sortedExpected = + typeof standardObject.ExpectedValue === "object" && + standardObject.ExpectedValue !== null + ? Object.keys(standardObject.ExpectedValue) + .sort() + .reduce((obj, key) => { + obj[key] = standardObject.ExpectedValue[key]; + return obj; + }, {}) + : standardObject.ExpectedValue; + isCompliant = + JSON.stringify(sortedCurrent) === JSON.stringify(sortedExpected); + } + // SECOND: Check if Value is explicitly true + else if (directStandardValue === true) { isCompliant = true; - } else if (currentTenantStandard) { + } + // THIRD: Fall back to currentTenantStandard + else if (currentTenantStandard) { isCompliant = currentTenantStandard.value === true; } @@ -220,8 +246,8 @@ const Page = () => { complianceStatus: isOverridden ? "Overridden" : isCompliant - ? "Compliant" - : "Non-Compliant", + ? "Compliant" + : "Non-Compliant", isOverridden, overridingTemplateId: isOverridden ? tenantTemplateId : null, overridingTemplateName, @@ -258,12 +284,12 @@ const Page = () => { if (itemTemplateId) { const standardId = `standards.IntuneTemplate.${itemTemplateId}`; const standardInfo = standards.find( - (s) => s.name === `standards.IntuneTemplate` + (s) => s.name === `standards.IntuneTemplate`, ); // Find the tenant's value for this specific template const currentTenantStandard = currentTenantData.find( - (s) => s.standardId === standardId + (s) => s.standardId === standardId, ); // Get the standard object and its value from the tenant object @@ -320,8 +346,8 @@ const Page = () => { complianceStatus: isOverridden ? "Overridden" : isCompliant - ? "Compliant" - : "Non-Compliant", + ? "Compliant" + : "Non-Compliant", isOverridden, overridingTemplateId: isOverridden ? tenantTemplateId : null, overridingTemplateName, @@ -374,12 +400,12 @@ const Page = () => { const itemTemplateId = expandedTemplate.GUID; const standardId = `standards.ConditionalAccessTemplate.${itemTemplateId}`; const standardInfo = standards.find( - (s) => s.name === `standards.ConditionalAccessTemplate` + (s) => s.name === `standards.ConditionalAccessTemplate`, ); // Find the tenant's value for this specific template const currentTenantStandard = currentTenantData.find( - (s) => s.standardId === standardId + (s) => s.standardId === standardId, ); const standardObject = currentTenantObj?.[standardId]; const directStandardValue = standardObject?.Value; @@ -390,10 +416,40 @@ const Page = () => { : null; let isCompliant = false; - // For ConditionalAccessTemplate, the value is true if compliant, or an object with comparison data if not compliant - if (directStandardValue === true) { + // FIRST: Check if CurrentValue and ExpectedValue exist and match + if ( + standardObject?.CurrentValue !== undefined && + standardObject?.ExpectedValue !== undefined + ) { + const sortedCurrent = + typeof standardObject.CurrentValue === "object" && + standardObject.CurrentValue !== null + ? Object.keys(standardObject.CurrentValue) + .sort() + .reduce((obj, key) => { + obj[key] = standardObject.CurrentValue[key]; + return obj; + }, {}) + : standardObject.CurrentValue; + const sortedExpected = + typeof standardObject.ExpectedValue === "object" && + standardObject.ExpectedValue !== null + ? Object.keys(standardObject.ExpectedValue) + .sort() + .reduce((obj, key) => { + obj[key] = standardObject.ExpectedValue[key]; + return obj; + }, {}) + : standardObject.ExpectedValue; + isCompliant = + JSON.stringify(sortedCurrent) === JSON.stringify(sortedExpected); + } + // SECOND: Check if Value is explicitly true + else if (directStandardValue === true) { isCompliant = true; - } else { + } + // THIRD: Otherwise not compliant + else { isCompliant = false; } @@ -423,8 +479,8 @@ const Page = () => { complianceStatus: isOverridden ? "Overridden" : isCompliant - ? "Compliant" - : "Non-Compliant", + ? "Compliant" + : "Non-Compliant", complianceDetails: standardInfo?.docsDescription || standardInfo?.helpText || "", standardDescription: standardInfo?.helpText || "", @@ -461,12 +517,12 @@ const Page = () => { if (itemTemplateId) { const standardId = `standards.ConditionalAccessTemplate.${itemTemplateId}`; const standardInfo = standards.find( - (s) => s.name === `standards.ConditionalAccessTemplate` + (s) => s.name === `standards.ConditionalAccessTemplate`, ); // Find the tenant's value for this specific template const currentTenantStandard = currentTenantData.find( - (s) => s.standardId === standardId + (s) => s.standardId === standardId, ); const standardObject = currentTenantObj?.[standardId]; const directStandardValue = standardObject?.Value; @@ -509,8 +565,8 @@ const Page = () => { complianceStatus: isOverridden ? "Overridden" : isCompliant - ? "Compliant" - : "Non-Compliant", + ? "Compliant" + : "Non-Compliant", complianceDetails: standardInfo?.docsDescription || standardInfo?.helpText || "", standardDescription: standardInfo?.helpText || "", @@ -553,7 +609,7 @@ const Page = () => { // Find the tenant's value for this template const currentTenantStandard = currentTenantData.find( - (s) => s.standardId === standardId + (s) => s.standardId === standardId, ); const standardObject = currentTenantObj?.[standardId]; const directStandardValue = standardObject?.Value; @@ -633,8 +689,8 @@ const Page = () => { complianceStatus: isOverridden ? "Overridden" : isCompliant - ? "Compliant" - : "Non-Compliant", + ? "Compliant" + : "Non-Compliant", complianceDetails: standardInfo?.docsDescription || standardInfo?.helpText || "", standardDescription: standardInfo?.helpText || "", standardImpact: standardInfo?.impact || "Medium Impact", @@ -674,44 +730,119 @@ const Page = () => { actions.filter( (action) => action?.value.toLowerCase() === "report" || - action?.value.toLowerCase() === "remediate" + action?.value.toLowerCase() === "remediate", ).length > 0; // Find the tenant's value for this standard const currentTenantStandard = currentTenantData.find( - (s) => s.standardId === standardId + (s) => s.standardId === standardId, ); // Check if the standard is directly in the tenant object (like "standards.AuditLog": {...}) const standardIdWithoutPrefix = standardId.replace("standards.", ""); const standardObject = currentTenantObj?.[standardId]; + console.log( + "standardId:", + standardId, + "includes IntuneTag:", + standardId.includes("IntuneTag"), + ); + + // Debug logging for Intune tags + if (standardId.includes("IntuneTag") || standardId.includes("intuneTag")) { + console.log(`[${standardId}] standardObject:`, { + standardObject, + hasCurrentValue: standardObject?.CurrentValue !== undefined, + hasExpectedValue: standardObject?.ExpectedValue !== undefined, + Value: standardObject?.Value, + CurrentValue: standardObject?.CurrentValue, + ExpectedValue: standardObject?.ExpectedValue, + }); + } + // Extract the actual value from the standard object (new data structure includes .Value property) const directStandardValue = standardObject?.Value; - // Determine compliance - use backend's logic: Value === true OR CurrentValue === ExpectedValue + // Determine compliance - prioritize Value field, then CurrentValue/ExpectedValue comparison let isCompliant = false; let reportingDisabled = !reportingEnabled; - if (directStandardValue === true) { - // Boolean true means compliant + // Helper function to compare values, handling arrays with order-independent comparison + const compareValues = (val1, val2) => { + // If both are arrays, compare as sets (order-independent) + if (Array.isArray(val1) && Array.isArray(val2)) { + if (val1.length !== val2.length) return false; + // Sort both arrays by their JSON representation for consistent comparison + const sorted1 = [...val1].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)), + ); + const sorted2 = [...val2].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)), + ); + return JSON.stringify(sorted1) === JSON.stringify(sorted2); + } + // For objects, sort keys to ensure consistent comparison + if ( + typeof val1 === "object" && + val1 !== null && + typeof val2 === "object" && + val2 !== null + ) { + const sortedVal1 = Object.keys(val1) + .sort() + .reduce((obj, key) => { + obj[key] = val1[key]; + return obj; + }, {}); + const sortedVal2 = Object.keys(val2) + .sort() + .reduce((obj, key) => { + obj[key] = val2[key]; + return obj; + }, {}); + return JSON.stringify(sortedVal1) === JSON.stringify(sortedVal2); + } + // Otherwise use standard JSON comparison + return JSON.stringify(val1) === JSON.stringify(val2); + }; + + // FIRST: Check if CurrentValue and ExpectedValue exist and match (most reliable) + if ( + standardObject?.CurrentValue !== undefined && + standardObject?.ExpectedValue !== undefined + ) { + isCompliant = compareValues( + standardObject.CurrentValue, + standardObject.ExpectedValue, + ); + // Debug logging for Intune tags + if (standardId.includes("IntuneTag") || standardId.includes("intuneTag")) { + console.log(`[${standardId}] Comparing CurrentValue vs ExpectedValue:`, { + CurrentValue: standardObject.CurrentValue, + ExpectedValue: standardObject.ExpectedValue, + isCompliant, + currentJSON: JSON.stringify(standardObject.CurrentValue), + expectedJSON: JSON.stringify(standardObject.ExpectedValue), + areEqual: + JSON.stringify(standardObject.CurrentValue) === + JSON.stringify(standardObject.ExpectedValue), + }); + } + } + // SECOND: Check if Value is explicitly true (compliant) or false (non-compliant) + else if (directStandardValue === true) { isCompliant = true; - } else if (standardObject?.CurrentValue && standardObject?.ExpectedValue) { - // Compare CurrentValue and ExpectedValue (backend's comparison logic) - isCompliant = - JSON.stringify(standardObject.CurrentValue) === - JSON.stringify(standardObject.ExpectedValue); - } else if (standardObject?.CurrentValue && standardObject?.ExpectedValue) { - // Compare CurrentValue and ExpectedValue (backend's comparison logic) - isCompliant = - JSON.stringify(standardObject.CurrentValue) === - JSON.stringify(standardObject.ExpectedValue); - } else if (directStandardValue !== undefined) { - // For non-boolean values, use strict equality + } else if (directStandardValue === false) { + isCompliant = false; + } + // THIRD: For other non-boolean, non-null values, use strict equality with template settings + else if (directStandardValue !== undefined && directStandardValue !== null) { isCompliant = JSON.stringify(directStandardValue) === JSON.stringify(standardSettings); - } else if (currentTenantStandard) { - // Fall back to the previous logic if the standard is not directly in the tenant object + } + // FOURTH: Fall back to the previous logic if the standard is not directly in the tenant object + else if (currentTenantStandard) { if (typeof standardSettings === "boolean" && standardSettings === true) { isCompliant = currentTenantStandard.value === true; } else { @@ -725,8 +856,8 @@ const Page = () => { const complianceStatus = reportingDisabled ? "Reporting Disabled" : isCompliant - ? "Compliant" - : "Non-Compliant"; + ? "Compliant" + : "Non-Compliant"; // Check if this standard is overridden by another template const tenantTemplateId = standardObject?.TemplateId; @@ -913,14 +1044,14 @@ const Page = () => { const compliancePercentage = allCount > 0 ? Math.round( - (compliantCount / (allCount - reportingDisabledCount - overriddenCount || 1)) * 100 + (compliantCount / (allCount - reportingDisabledCount - overriddenCount || 1)) * 100, ) : 0; const missingLicensePercentage = allCount > 0 ? Math.round( - (missingLicenseCount / (allCount - reportingDisabledCount - overriddenCount || 1)) * 100 + (missingLicenseCount / (allCount - reportingDisabledCount - overriddenCount || 1)) * 100, ) : 0; @@ -1102,7 +1233,7 @@ const Page = () => { query: query, }, undefined, - { shallow: true } + { shallow: true }, ); }} sx={{ width: 300 }} @@ -1198,8 +1329,8 @@ const Page = () => { compliancePercentage === 100 ? "success" : compliancePercentage >= 50 - ? "warning" - : "error" + ? "warning" + : "error" } /> { missingLicensePercentage === 0 ? "success" : missingLicensePercentage <= 25 - ? "warning" - : "error" + ? "warning" + : "error" } /> { standard.complianceStatus === "Compliant" ? "success.main" : standard.complianceStatus === "Overridden" - ? "warning.main" - : standard.complianceStatus === "Reporting Disabled" - ? "grey.500" - : "error.main", + ? "warning.main" + : standard.complianceStatus === "Reporting Disabled" + ? "grey.500" + : "error.main", }} > {standard.complianceStatus === "Compliant" ? ( @@ -1481,78 +1612,94 @@ const Page = () => { - {!standard.standardValue ? ( - - This data has not yet been collected. Collect the data by pressing - the report button on the top of the page. - - ) : ( + {/* Show Expected Configuration with property-by-property breakdown */} + {standard.currentTenantValue?.ExpectedValue !== undefined ? ( - - - {standard.standardValue && - typeof standard.standardValue === "object" && - Object.keys(standard.standardValue).length > 0 ? ( - Object.entries(standard.standardValue).map(([key, value]) => ( - + + Expected Configuration + + {typeof standard.currentTenantValue.ExpectedValue === "object" && + standard.currentTenantValue.ExpectedValue !== null ? ( + + {Object.entries(standard.currentTenantValue.ExpectedValue).map( + ([key, val]) => ( + - {key}: + {key} - - {typeof value === "object" && value !== null - ? value?.label || JSON.stringify(value) - : value === true - ? "Enabled" - : value === false - ? "Disabled" - : String(value)} - + + {val !== undefined + ? JSON.stringify(val, null, 2) + : "Not set"} + + - )) - ) : ( - - {standard.standardValue === true ? ( - - This setting is configured correctly - - ) : standard.standardValue === false ? ( - - This setting is not configured correctly - - ) : standard.standardValue !== undefined ? ( - typeof standard.standardValue === "object" ? ( - "No settings configured" - ) : ( - String(standard.standardValue) - ) - ) : ( - - 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 - - )} - + ), )} + + ) : ( + + + {String(standard.currentTenantValue.ExpectedValue)} + - + )} + ) : ( + + This data has not yet been collected. Collect the data by pressing + the report button on the top of the page. + )} @@ -1563,8 +1710,8 @@ const Page = () => { standard.standardImpactColour === "info" ? "info" : standard.standardImpactColour === "warning" - ? "warning" - : "error" + ? "warning" + : "error" } sx={{ mr: 1 }} /> @@ -1629,10 +1776,10 @@ const Page = () => { standard.complianceStatus === "Compliant" ? "success.main" : standard.complianceStatus === "Overridden" - ? "warning.main" - : standard.complianceStatus === "Reporting Disabled" - ? "grey.500" - : "error.main", + ? "warning.main" + : standard.complianceStatus === "Reporting Disabled" + ? "grey.500" + : "error.main", borderRadius: "50%", width: 8, height: 8, @@ -1652,7 +1799,7 @@ const Page = () => { } size="small" label={`${new Date( - standard.currentTenantValue.LastRefresh + standard.currentTenantValue.LastRefresh, ).toLocaleString()}`} variant="outlined" /> @@ -1689,109 +1836,244 @@ const Page = () => { ) : standard.complianceStatus === "Compliant" ? ( <> - - This setting is configured correctly - - - ) : standard.currentTenantValue?.Value === false ? ( - <> - - This setting is not configured correctly - - {/* Show Current/Expected values for non-compliant standards */} - {standard.currentTenantValue?.CurrentValue && - standard.currentTenantValue?.ExpectedValue && ( - - - - Expected - - + {/* 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]) => ( + - {JSON.stringify( - standard.currentTenantValue.ExpectedValue, - null, - 2 - )} + {key} + + + + + + {val !== undefined + ? JSON.stringify(val, null, 2) + : "Not set"} + + - - + ))} + + ) : ( + + + Current Configuration + + - Current + {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]) => ( + - {JSON.stringify( - standard.currentTenantValue.CurrentValue, - null, - 2 - )} + {key} + + + {val !== undefined + ? JSON.stringify(val, null, 2) + : "Not set"} + + + ))} + + ) : ( + + + Current Configuration + + + + {String(standard.currentTenantValue.CurrentValue)} + - )} + ))} - ) : null} + )} {/* Only show values if they're not simple true/false that's already covered by the alerts above */} {!( @@ -1810,7 +2092,7 @@ const Page = () => { key === "Value" && (standard.currentTenantValue?.Value === true || standard.currentTenantValue?.Value === false) - ) + ), ) .map(([key, value]) => { const actualValue = key === "Value" ? value : value; @@ -1858,8 +2140,8 @@ const Page = () => { standard.complianceStatus === "Compliant" ? "success.main" : isDifferent - ? "error.main" - : "inherit", + ? "error.main" + : "inherit", fontWeight: standard.complianceStatus === "Non-Compliant" && isDifferent @@ -1902,10 +2184,10 @@ const Page = () => { standard.complianceStatus === "Compliant" ? "success.main" : standard.complianceStatus === "Overridden" - ? "warning.main" - : standard.complianceStatus === "Reporting Disabled" - ? "text.secondary" - : "error.main", + ? "warning.main" + : standard.complianceStatus === "Reporting Disabled" + ? "text.secondary" + : "error.main", fontWeight: standard.complianceStatus === "Non-Compliant" ? "medium" @@ -1924,26 +2206,265 @@ const Page = () => { standard.overridingTemplateId} ) : standard.complianceStatus === "Compliant" ? ( - - This setting is configured correctly - - ) : standard.currentTenantValue?.Value === false || - standard.currentTenantValue === false ? ( - - This setting is not configured correctly - - ) : standard.currentTenantValue !== undefined ? ( - String( - standard.currentTenantValue?.Value !== undefined - ? standard.currentTenantValue?.Value - : standard.currentTenantValue - ) + <> + {/* 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)} + + + + ) + ) : null} + ) : ( - - 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 - + <> + {(standard.currentTenantValue?.Value === false || + standard.currentTenantValue === false) && ( + + This setting is not configured correctly + + )} + {/* 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]) => ( + + + {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, + )} + + ) : standard.currentTenantValue === undefined || + (standard.currentTenantValue?.Value === null && + standard.currentTenantValue?.CurrentValue === undefined && + standard.currentTenantValue?.ExpectedValue === + undefined) ? ( + + This setting is not configured, or data has not been + collected. If you are getting this after data collection, + the tenant might not be licensed for this feature + + ) : null} + )} )} From a9d0c78d34311d86cd56c037009f26775af31627 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 20 Jan 2026 15:14:07 -0500 Subject: [PATCH 12/17] Add tenant region display to lookup page Displays the tenant region using OpenIdConfig. Also updates the fallback text for tenant brand name from 'N/A' to 'Not Specified'. --- src/pages/tenant/tools/tenantlookup/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/tenant/tools/tenantlookup/index.js b/src/pages/tenant/tools/tenantlookup/index.js index 1678f428f818..08453771819d 100644 --- a/src/pages/tenant/tools/tenantlookup/index.js +++ b/src/pages/tenant/tools/tenantlookup/index.js @@ -84,7 +84,11 @@ const Page = () => { Tenant Brand Name :{" "} {getTenant.data?.GraphRequest?.federationBrandName ? getTenant.data?.GraphRequest?.federationBrandName - : "N/A"} + : "Not Specified"} + + + Tenant Region:{" "} + {getTenant.data?.OpenIdConfig?.tenant_region_scope} From f634ee4907ec1e022541f6f1c8f0b521cb164f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Tue, 20 Jan 2026 22:31:31 +0100 Subject: [PATCH 13/17] feat(licenses): add restore default excluded licenses - Introduced a new button to restore default excluded licenses. - Added a dialog for confirming the restore action. --- src/pages/cipp/settings/licenses.js | 69 ++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index f59032fbcba5..3858248dc0e8 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -2,17 +2,17 @@ import tabOptions from "./tabOptions"; import { TabbedLayout } from "/src/layouts/TabbedLayout"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Button, SvgIcon } from "@mui/material"; +import { Button, SvgIcon, Stack } from "@mui/material"; import { TrashIcon } from "@heroicons/react/24/outline"; -import { Add } from "@mui/icons-material"; +import { Add, RestartAlt } from "@mui/icons-material"; import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; import { useDialog } from "../../../hooks/use-dialog"; const Page = () => { const pageTitle = "Excluded Licenses"; - const apiUrl = "/api/ExecExcludeLicenses"; - const apiData = { List: true }; + const apiUrl = "/api/ListExcludedLicenses"; const createDialog = useDialog(); + const resetDialog = useDialog(); const simpleColumns = ["Product_Display_Name", "GUID"]; const actions = [ @@ -31,21 +31,32 @@ const Page = () => { }, ]; - const AddExcludedLicense = () => { + const CardButtons = () => { return ( - + + + + ); }; @@ -60,8 +71,7 @@ const Page = () => { title={pageTitle} queryKey="ExcludedLicenses" apiUrl={apiUrl} - cardButton={} - apiData={apiData} + cardButton={} actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} @@ -94,6 +104,25 @@ const Page = () => { relatedQueryKeys: ["ExcludedLicenses"], }} /> + ); }; From 7d239e25c81e0e6a2713ee917930c4ba9086b60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Tue, 20 Jan 2026 22:54:27 +0100 Subject: [PATCH 14/17] feat(licenses): standardize API calls for exclusion actions --- src/pages/cipp/settings/licenses.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index 3858248dc0e8..89abb862d188 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -19,8 +19,8 @@ const Page = () => { { label: "Delete Exclusion", type: "POST", - url: "/api/ExecExcludeLicenses?RemoveExclusion=true", - data: { GUID: "GUID" }, + url: "/api/ExecExcludeLicenses", + data: { Action: "!RemoveExclusion", GUID: "GUID" }, confirmText: "Do you want to delete this exclusion?", color: "error", icon: ( @@ -73,6 +73,7 @@ const Page = () => { apiUrl={apiUrl} cardButton={} actions={actions} + apiDataKey="Results" offCanvas={offCanvas} simpleColumns={simpleColumns} tenantInTitle={false} @@ -95,11 +96,11 @@ const Page = () => { }, ]} api={{ - url: "/api/ExecExcludeLicenses?AddExclusion=true", + url: "/api/ExecExcludeLicenses", confirmText: "Add a license to the exclusion table, make sure to enter the correct GUID and SKU Name", type: "POST", - data: {}, + data: { Action: "!AddExclusion" }, replacementBehaviour: "removeNulls", relatedQueryKeys: ["ExcludedLicenses"], }} @@ -115,11 +116,11 @@ const Page = () => { }, ]} api={{ - url: "/api/ExecExcludeLicenses?ResetToDefaults=true", + 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: {}, + data: { Action: "!RestoreDefaults" }, relatedQueryKeys: ["ExcludedLicenses"], }} /> From 2bd5a123610e3d706c8b00d241d10b67456bbf14 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 20 Jan 2026 23:33:36 -0500 Subject: [PATCH 15/17] Add license assignment states to user and group pages Extended user and group pages to display additional license-related fields, including 'licenseAssignmentStates' for users and several license and sync fields for groups. Updated getCippFormatting to handle and format 'licenseAssignmentStates' for display, including transforming and rendering the data as a table. --- .../identity/administration/groups/index.js | 7 +- .../identity/administration/users/index.js | 4 +- src/utils/get-cipp-formatting.js | 73 +++++++++++++------ 3 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/pages/identity/administration/groups/index.js b/src/pages/identity/administration/groups/index.js index af5182224a12..4289bb9d01e6 100644 --- a/src/pages/identity/administration/groups/index.js +++ b/src/pages/identity/administration/groups/index.js @@ -287,6 +287,11 @@ const Page = () => { "mailEnabled", "securityEnabled", "visibility", + "assignedLicenses", + "licenseProcessingState.state", + "onPremisesSamAccountName", + "membershipRule", + "onPremisesSyncEnabled", ], actions: actions, }; @@ -320,11 +325,11 @@ const Page = () => { "mailNickname", "groupType", "assignedLicenses", + "licenseProcessingState.state", "visibility", "onPremisesSamAccountName", "membershipRule", "onPremisesSyncEnabled", - "userPrincipalName", ]} /> ); diff --git a/src/pages/identity/administration/users/index.js b/src/pages/identity/administration/users/index.js index 27148701979b..8f5d613095d1 100644 --- a/src/pages/identity/administration/users/index.js +++ b/src/pages/identity/administration/users/index.js @@ -50,6 +50,7 @@ const Page = () => { "onPremisesLastSyncDateTime", // OnPrem Last Sync "onPremisesDistinguishedName", // OnPrem DN "otherMails", // Alternate Email Addresses + "licenseAssignmentStates", // License Assignment States ], actions: userActions, }; @@ -85,7 +86,7 @@ const Page = () => { Endpoint: "users", manualPagination: true, $select: - "id,accountEnabled,businessPhones,city,createdDateTime,companyName,country,department,displayName,faxNumber,givenName,isResourceAccount,jobTitle,mail,mailNickname,mobilePhone,officeLocation,otherMails,postalCode,preferredDataLocation,preferredLanguage,proxyAddresses,showInAddressList,state,streetAddress,surname,usageLocation,userPrincipalName,userType,assignedLicenses,onPremisesSyncEnabled,OnPremisesImmutableId,onPremisesLastSyncDateTime,onPremisesDistinguishedName", + "id,accountEnabled,businessPhones,city,createdDateTime,companyName,country,department,displayName,faxNumber,givenName,isResourceAccount,jobTitle,mail,mailNickname,mobilePhone,officeLocation,otherMails,postalCode,preferredDataLocation,preferredLanguage,proxyAddresses,showInAddressList,state,streetAddress,surname,usageLocation,userPrincipalName,userType,assignedLicenses,licenseAssignmentStates,onPremisesSyncEnabled,OnPremisesImmutableId,onPremisesLastSyncDateTime,onPremisesDistinguishedName", $count: true, $orderby: "displayName", $top: 999, @@ -101,6 +102,7 @@ const Page = () => { "businessPhones", "proxyAddresses", "assignedLicenses", + "licenseAssignmentStates", ]} filters={filters} /> diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index f47559d34499..68521b0781df 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -249,14 +249,14 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr unit === "d" ? "day" : unit === "h" - ? "hour" - : unit === "w" - ? "week" - : unit === "m" - ? "minutes" - : unit === "y" - ? "year" - : unit; + ? "hour" + : unit === "w" + ? "week" + : unit === "m" + ? "minutes" + : unit === "y" + ? "year" + : unit; return isText ? `Every ${value} ${unitText}` : `Every ${value} ${unitText}`; } } @@ -352,7 +352,7 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr icon: icon, key: key, }; - }) + }), ); } else { // Handle null/undefined single element @@ -459,7 +459,7 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr : renderChipList( data .filter((item) => item) - .map((item) => (typeof item === "object" && item?.label ? item.label : item)) + .map((item) => (typeof item === "object" && item?.label ? item.label : item)), ); } } @@ -500,12 +500,12 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr normalized === "enabled" ? "Enabled" : normalized === "disabled" - ? "Disabled" - : normalized === "enabledforreportingbutnotenforced" || - normalized === "report-only" || - normalized === "reportonly" - ? "Report Only" - : data.charAt(0).toUpperCase() + data.slice(1); + ? "Disabled" + : normalized === "enabledforreportingbutnotenforced" || + normalized === "report-only" || + normalized === "reportonly" + ? "Report Only" + : data.charAt(0).toUpperCase() + data.slice(1); if (isText) { return label; @@ -561,8 +561,8 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr const accessRights = Array.isArray(data) ? data.flatMap((item) => (typeof item === "string" ? item.split(", ") : [])) : typeof data === "string" - ? data.split(", ") - : []; + ? data.split(", ") + : []; return isText ? accessRights.join(", ") : renderChipList(accessRights); } @@ -643,8 +643,37 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr ? translatedLicenses.join(", ") : translatedLicenses : Array.isArray(translatedLicenses) - ? renderChipList(translatedLicenses) - : translatedLicenses; + ? renderChipList(translatedLicenses) + : translatedLicenses; + } + + // Handle license assignment states + if (cellName === "licenseAssignmentStates") { + if (!Array.isArray(data) || data.length === 0) { + return []; + } + + // Transform the array to replace skuId with translated name and remove disabledPlans + const transformedData = data.map((license) => { + const translatedLicense = getCippLicenseTranslation([license]); + const licenseName = Array.isArray(translatedLicense) + ? translatedLicense[0] + : translatedLicense; + + // Return new object with skuId replaced by License and without disabledPlans + const { skuId, disabledPlans, ...rest } = license; + return { + License: licenseName, + ...rest, + }; + }); + + // Render as a table + return isText ? ( + JSON.stringify(transformedData) + ) : ( + + ); } if (cellName === "unifiedRoles") { @@ -849,7 +878,7 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr }, { fallbackLocale: "en", - } + }, ); const duration = isoDuration(data); return duration.humanize("en"); @@ -904,7 +933,7 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr return { label: item.label, }; - }) + }), ); } From a546b734ac7628f55fb249bf8a0e089e56798617 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 21 Jan 2026 00:28:13 -0500 Subject: [PATCH 16/17] Add 'Reprocess License Assignments' user action Introduces a new user action for reprocessing license assignments, including confirmation text and conditional availability based on write permissions. implements #5163 --- src/components/CippComponents/CippUserActions.jsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index 9d3e284e3678..9b79b58fd32c 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -333,7 +333,7 @@ export const useCippUserActions = () => { labelField: (option) => option?.calculatedGroupType ? `${option.displayName} (${option.calculatedGroupType})` - : option?.displayName ?? "", + : (option?.displayName ?? ""), valueField: "id", addedField: { groupType: "groupType", @@ -549,6 +549,17 @@ export const useCippUserActions = () => { "Are you sure you want to change the source of authority for [userPrincipalName]? Setting it to On-Premises Managed will take until the next sync cycle to show the change.", multiPost: false, }, + { + label: "Reprocess License Assignments", + type: "POST", + icon: , + url: "/api/ExecReprocessUserLicenses", + data: { ID: "id", userPrincipalName: "userPrincipalName" }, + confirmText: + "Are you sure you want to reprocess license assignments for [userPrincipalName]?", + multiPost: false, + condition: (row) => canWriteUser, + }, { label: "Revoke all user sessions", type: "POST", From 9c44b99fc13b75000ad23d1e3e00f0f7c6b9799c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 21 Jan 2026 09:54:50 -0500 Subject: [PATCH 17/17] bump version to 10.0.3 --- package.json | 2 +- public/version.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ac17d5b76536..bdcffddcdf11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.0.1", + "version": "10.0.3", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/public/version.json b/public/version.json index 028831b26807..c05615499486 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.0.2" + "version": "10.0.3" }