From cea0c9b76a84a4f66f66aed400f4e563f4f8ee13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 27 Jul 2026 14:21:21 +0000 Subject: [PATCH] Merge pull request #79 from CyberDrain/dev Dev to hf Synced from CyberDrain/CIPP@7d7036e7520ceb35c0f069ebd0f474c57ddc1078 --- package.json | 2 +- public/version.json | 2 +- .../CippPolicyCompareDialog.jsx | 174 ++++++++++++++++++ .../CippComponents/CippPolicyDiffTable.jsx | 112 +++++++++++ .../CippComponents/CippTenantSelector.jsx | 38 ++-- .../CippSettings/CippGDAP/CippGDAPTrace.jsx | 4 +- .../CippSettings/CippPermissionReport.jsx | 6 +- .../CippSettings/CippTenantResults.jsx | 13 +- .../CippWizard/CippWizardVacationActions.jsx | 5 +- .../CippWizard/OnboardingWizardPage.jsx | 21 ++- src/data/portals.json | 1 + src/pages/cipp/logs/logentry.js | 111 ++++++++--- src/pages/cipp/settings/permissions.js | 32 +++- src/pages/cipp/settings/tenants.js | 21 +++ src/pages/dashboardv1.js | 7 +- src/pages/dashboardv2/index.js | 7 +- .../endpoint/MEM/compare-policies/index.js | 90 +-------- .../teams-share/teams/business-voice/index.js | 16 +- src/pages/tenant/manage/applied-standards.js | 35 ++++ src/pages/tenant/manage/drift.js | 133 +++++++------ src/utils/get-cipp-formatting.js | 2 + 21 files changed, 622 insertions(+), 210 deletions(-) create mode 100644 src/components/CippComponents/CippPolicyCompareDialog.jsx create mode 100644 src/components/CippComponents/CippPolicyDiffTable.jsx diff --git a/package.json b/package.json index 292491020d6d..5c3967584cb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.7.0", + "version": "10.7.1", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/public/version.json b/public/version.json index a0adb1556b16..ac3d65fd9d1c 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.7.0" + "version": "10.7.1" } diff --git a/src/components/CippComponents/CippPolicyCompareDialog.jsx b/src/components/CippComponents/CippPolicyCompareDialog.jsx new file mode 100644 index 000000000000..123c9477fee2 --- /dev/null +++ b/src/components/CippComponents/CippPolicyCompareDialog.jsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo } from 'react' +import { + Alert, + AlertTitle, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Skeleton, + Stack, + Typography, +} from '@mui/material' +import { + CheckCircle as CheckCircleIcon, + CompareArrows as CompareArrowsIcon, + Error as ErrorIcon, + Info as InfoIcon, + PolicyOutlined as PolicyOffIcon, +} from '@mui/icons-material' +import { ApiPostCall } from '../../api/ApiCall' +import CippJsonView from '../CippFormPages/CippJSONView' +import { CippPolicyDiffTable } from './CippPolicyDiffTable' + +/** + * Live "compare to baseline" for a standard backed by an Intune template. + * + * The drift and standards pages know which template a standard points at but not which policy it + * landed on in the tenant, so the tenant side is resolved server-side by the template's display + * name - the same lookup the IntuneTemplate standard itself performs. The baseline side is sent + * with the tenant so it goes through the standard's reusable-settings sync and text replacement, + * which is what makes the differences shown here match what drift reports. + */ +export const CippPolicyCompareDialog = ({ + open, + onClose, + tenantFilter, + templateGuid, + templateName, + standardsTemplateId, +}) => { + const compareApi = ApiPostCall({ relatedQueryKeys: [] }) + + useEffect(() => { + if (!open || !tenantFilter || !templateGuid) return + compareApi.mutate({ + url: '/api/ExecCompareIntunePolicy', + data: { + sourceA: { type: 'template', templateGuid, tenantFilter }, + // standardsTemplateId lets the lookup honour the standard's fuzzy match setting, so the + // comparison targets the policy remediation would actually overwrite. + sourceB: { type: 'tenantPolicyByTemplate', templateGuid, tenantFilter, standardsTemplateId }, + }, + }) + // Refire on every open so the comparison is always against the tenant's current state. + }, [open, tenantFilter, templateGuid, standardsTemplateId]) + + const results = useMemo(() => { + if (!compareApi.isSuccess) return null + return compareApi.data?.data || compareApi.data + }, [compareApi.isSuccess, compareApi.data]) + + const errorMessage = useMemo(() => { + if (!compareApi.isError) return null + const errData = compareApi.error?.response?.data + return errData?.Results || compareApi.error?.message || 'An error occurred' + }, [compareApi.isError, compareApi.error]) + + const comparisonRows = useMemo(() => { + const raw = results?.Results + const rows = Array.isArray(raw) ? raw : raw ? [raw] : [] + return rows.filter((row) => row && typeof row === 'object') + }, [results?.Results]) + + const handleClose = () => { + compareApi.reset() + onClose() + } + + return ( + + Compare to baseline{templateName ? ` - ${templateName}` : ''} + + {compareApi.isPending && ( + + + + + )} + + {errorMessage && ( + }> + {errorMessage} + + )} + + {results && ( + + {results.policyMissing ? ( + }> + Not deployed to {tenantFilter} + + No Intune policy named{' '} + + {results.missingPolicyName || templateName || 'this policy'} + {' '} + exists in this tenant, so there is nothing to compare against. This is why the + standard reports it as non-compliant. + + + {results.fuzzyDistance > 0 + ? `No similarly named policy was found either - this standard allows a name to differ by up to ${results.fuzzyDistance} character${results.fuzzyDistance === 1 ? '' : 's'}. The baseline below is what gets created when the standard next remediates.` + : 'This standard matches a tenant policy by exact display name, so a policy that was renamed in the tenant shows up here too. Raising the fuzzy match distance on the standard would let it match a renamed policy. Otherwise the baseline below is what gets created when the standard next remediates.'} + + + ) : ( + : } + > + {results.identical + ? 'The tenant policy matches the baseline template - no differences found.' + : `${comparisonRows.length} difference${ + comparisonRows.length === 1 ? '' : 's' + } between the baseline template and the tenant policy.`} + + )} + + {results.matchType === 'fuzzy' && ( + }> + Matched by similar name + No policy exactly named{' '} + {templateName || 'the template'} exists in this tenant, + so this compares against{' '} + {results.matchedName} - the policy the standard's + fuzzy match setting would overwrite on the next remediation. + + )} + + {!results.identical && comparisonRows.length > 0 && ( + + )} + + {results.sourceAData && ( + + )} + + {results.sourceBData && ( + + )} + + )} + + + + + + ) +} + +export default CippPolicyCompareDialog diff --git a/src/components/CippComponents/CippPolicyDiffTable.jsx b/src/components/CippComponents/CippPolicyDiffTable.jsx new file mode 100644 index 000000000000..8accfbaf24da --- /dev/null +++ b/src/components/CippComponents/CippPolicyDiffTable.jsx @@ -0,0 +1,112 @@ +import { + Box, + Chip, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material' + +const hasValue = (val) => val !== null && val !== undefined && val !== '' + +// A row carries the two sides in ExpectedValue/ReceivedValue. Which of them are populated is what +// distinguishes a genuine difference from a property that only one side has at all. +export const getDiffStatus = (row) => { + const a = hasValue(row.ExpectedValue) + const b = hasValue(row.ReceivedValue) + if (a && b) return 'different' + if (a) return 'onlyA' + if (b) return 'onlyB' + return 'equal' +} + +const diffStatusColors = { + different: 'error', + onlyA: 'warning', + onlyB: 'info', + equal: 'success', +} + +const diffRowColors = { + different: { dark: 'rgba(244, 67, 54, 0.08)', light: 'rgba(244, 67, 54, 0.04)' }, + onlyA: { dark: 'rgba(255, 152, 0, 0.08)', light: 'rgba(255, 152, 0, 0.04)' }, + onlyB: { dark: 'rgba(33, 150, 243, 0.08)', light: 'rgba(33, 150, 243, 0.04)' }, + equal: { dark: 'transparent', light: 'transparent' }, +} + +const getRowColor = (row, theme) => { + const colors = diffRowColors[getDiffStatus(row)] + return theme.palette.mode === 'dark' ? colors.dark : colors.light +} + +export const formatDiffValue = (val) => { + if (val === null || val === undefined) return N/A + if (typeof val === 'object') { + return ( + + {JSON.stringify(val, null, 2)} + + ) + } + return String(val) +} + +/** + * Renders the per-property differences returned by /api/ExecCompareIntunePolicy. + * + * labelA/labelB name the two sides in both the column headers and the status chips, so the same + * table reads correctly whether it is comparing two arbitrary sources or a tenant against its + * baseline template. + */ +export const CippPolicyDiffTable = ({ rows = [], labelA = 'Source A', labelB = 'Source B' }) => { + const diffChipLabels = { + different: 'Different', + onlyA: `Only in ${labelA}`, + onlyB: `Only in ${labelB}`, + equal: 'Equal', + } + + return ( + + + + + Property + {labelA} + {labelB} + Status + + + + {rows.map((row, index) => { + const status = getDiffStatus(row) + return ( + ({ backgroundColor: getRowColor(row, theme) })}> + {row.Property} + {formatDiffValue(row.ExpectedValue)} + {formatDiffValue(row.ReceivedValue)} + + + + + ) + })} + +
+
+ ) +} + +export default CippPolicyDiffTable diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index 2cca5a975d05..b49e85be99f4 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -85,7 +85,12 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { { key: "SharePoint_Admin", label: "SharePoint Portal", - link: `/api/ListSharePointAdminUrl?tenantFilter=${currentTenant?.value}`, + // The only portal whose host cannot be derived from the tenant - it has to be resolved + // through Graph. Use the URL the backend already resolved when it has one; otherwise fall + // back to the endpoint that resolves it and redirects. + link: + currentTenant?.addedFields?.sharepointAdminUrl || + `/api/ListSharePointAdminUrl?tenantFilter=${currentTenant?.value}`, icon: "Share", external: true, }, @@ -227,6 +232,7 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { displayName: matchingTenant.displayName, customerId: matchingTenant.customerId, initialDomainName: matchingTenant.initialDomainName, + sharepointAdminUrl: matchingTenant.SharepointAdminUrl, }, }); } @@ -287,6 +293,7 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { displayName: matchingTenant.displayName, customerId: matchingTenant.customerId, initialDomainName: matchingTenant.initialDomainName, + sharepointAdminUrl: matchingTenant.SharepointAdminUrl, }, } : { @@ -351,16 +358,25 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { onChange={(nv) => setSelectedTenant(nv)} options={ tenantList.isSuccess && tenantList.data && tenantList.data.length > 0 - ? tenantList.data.map(({ customerId, displayName, defaultDomainName, initialDomainName }) => ({ - value: defaultDomainName, - label: `${displayName} (${defaultDomainName})`, - addedFields: { - defaultDomainName: defaultDomainName, - displayName: displayName, - customerId: customerId, - initialDomainName: initialDomainName, - }, - })) + ? tenantList.data.map( + ({ + customerId, + displayName, + defaultDomainName, + initialDomainName, + SharepointAdminUrl, + }) => ({ + value: defaultDomainName, + label: `${displayName} (${defaultDomainName})`, + addedFields: { + defaultDomainName: defaultDomainName, + displayName: displayName, + customerId: customerId, + initialDomainName: initialDomainName, + sharepointAdminUrl: SharepointAdminUrl, + }, + }) + ) : [] } getOptionLabel={(option) => option?.label || ""} diff --git a/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx b/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx index 4cea7e5c94b4..7e24a05be48a 100644 --- a/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx +++ b/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx @@ -45,7 +45,9 @@ export function useCippGDAPTrace() { icon: , noConfirm: true, customFunction: (row) => ref.current?.open(row), - condition: (row) => row.displayName !== "*Partner Tenant", + // Direct tenants have no GDAP relationship to trace. + condition: (row) => + row.displayName !== "*Partner Tenant" && row.delegatedPrivilegeStatus !== "directTenant", }), [] ); diff --git a/src/components/CippSettings/CippPermissionReport.jsx b/src/components/CippSettings/CippPermissionReport.jsx index 8a56b995650b..7a7e72e74402 100644 --- a/src/components/CippSettings/CippPermissionReport.jsx +++ b/src/components/CippSettings/CippPermissionReport.jsx @@ -68,15 +68,17 @@ export const CippPermissionReport = (props) => { "DisplayName", "DefaultDomainName", "UserPrincipalName", + "ServiceAccount", "IPAddress", - "GDAPRoles", + "AssignedRoles", + "GDAPRoles", // reports exported before AssignedRoles was renamed ]; if (formData.redactCustomerData) { report.Tenants.Results = report?.Tenants?.Results?.map((tenant) => { customerProps.forEach((prop) => { if (tenant?.[prop]) { - if (prop === "GDAPRoles") { + if (prop === "AssignedRoles" || prop === "GDAPRoles") { tenant[prop] = tenant[prop].map((role) => { if (Array.isArray(role?.Group)) { role.Group = role.Group.map((group) => group?.split("@")[0]); diff --git a/src/components/CippSettings/CippTenantResults.jsx b/src/components/CippSettings/CippTenantResults.jsx index dc79285ccb4e..8185f564397b 100644 --- a/src/components/CippSettings/CippTenantResults.jsx +++ b/src/components/CippSettings/CippTenantResults.jsx @@ -14,17 +14,21 @@ export const CippTenantResults = (props) => { actions={[]} simpleColumns={[ "TenantName", + "TenantType", "LastRun", "GraphStatus", "ExchangeStatus", "MissingRoles", - "GDAPRoles", + "AssignedRoles", ]} offCanvas={{ extendedInfoFields: [ "TenantName", "TenantId", + "TenantType", "DefaultDomainName", + "ServiceAccount", + "ServiceAccountLastAuth", "LastRun", "GraphTest", "ExchangeTest", @@ -69,17 +73,22 @@ export const CippTenantResults = (props) => { ]} simpleColumns={[ "TenantName", + "TenantType", + "ServiceAccount", "LastRun", "GraphStatus", "ExchangeStatus", "MissingRoles", - "GDAPRoles", + "AssignedRoles", ]} offCanvas={{ extendedInfoFields: [ "TenantName", "TenantId", + "TenantType", "DefaultDomainName", + "ServiceAccount", + "ServiceAccountLastAuth", "LastRun", "GraphTest", "ExchangeTest", diff --git a/src/components/CippWizard/CippWizardVacationActions.jsx b/src/components/CippWizard/CippWizardVacationActions.jsx index 3f23bcc484a6..7a8db5b558c7 100644 --- a/src/components/CippWizard/CippWizardVacationActions.jsx +++ b/src/components/CippWizard/CippWizardVacationActions.jsx @@ -147,7 +147,10 @@ export const CippWizardVacationActions = (props) => { Vacation mode uses group-based exclusions for reliability. The exclusion group - follows the format: 'Vacation Exclusion - $Policy.displayName' + follows the format: 'Vacation Exclusion - $Policy.displayName'. For + longer policy names the name is shortened and suffixed with the start of the + policy ID, e.g. 'Vacation Exclusion - CA005-RegisterSecurityInfo: Require... + [a1b2c3d4]' diff --git a/src/components/CippWizard/OnboardingWizardPage.jsx b/src/components/CippWizard/OnboardingWizardPage.jsx index 6b0876ad9a58..3ae07c4e84da 100644 --- a/src/components/CippWizard/OnboardingWizardPage.jsx +++ b/src/components/CippWizard/OnboardingWizardPage.jsx @@ -22,6 +22,9 @@ const OnboardingWizardPage = () => { ? selectedOptionQuery[0] : selectedOptionQuery + const tenantTypeQuery = router.query?.tenantType + const deepLinkedTenantType = Array.isArray(tenantTypeQuery) ? tenantTypeQuery[0] : tenantTypeQuery + const setupOptions = [ { description: @@ -62,6 +65,13 @@ const OnboardingWizardPage = () => { typeof deepLinkedOption === 'string' && setupOptions.some((option) => option.value === deepLinkedOption) + // A deep link that already names the tenant type skips the type selection and lands the user + // straight on that type's step, e.g. re-authenticating a direct tenant from the tenants list. + const hasDeepLinkedTenantType = + hasDeepLinkedOption && + deepLinkedOption === 'AddTenant' && + ['GDAP', 'Direct', 'IndirectReseller'].includes(deepLinkedTenantType) + const steps = [ { description: 'Onboarding', @@ -90,7 +100,7 @@ const OnboardingWizardPage = () => { { description: 'Tenant Type', component: CippAddTenantTypeSelection, - showStepWhen: (values) => values?.selectedOption === 'AddTenant', + showStepWhen: (values) => values?.selectedOption === 'AddTenant' && !hasDeepLinkedTenantType, }, { description: 'Direct Tenant', @@ -155,7 +165,14 @@ const OnboardingWizardPage = () => { steps={steps} wizardTitle="Setup Wizard" postUrl="/api/ExecCombinedSetup" - initialState={hasDeepLinkedOption ? { selectedOption: deepLinkedOption } : undefined} + initialState={ + hasDeepLinkedOption + ? { + selectedOption: deepLinkedOption, + ...(hasDeepLinkedTenantType && { tenantType: deepLinkedTenantType }), + } + : undefined + } /> ) } diff --git a/src/data/portals.json b/src/data/portals.json index 30709d4b30d7..57fc5853ee2f 100644 --- a/src/data/portals.json +++ b/src/data/portals.json @@ -58,6 +58,7 @@ "name": "SharePoint_Admin", "url": "/api/ListSharePointAdminUrl?tenantFilter=defaultDomainName", "variable": "defaultDomainName", + "field": "SharepointAdminUrl", "target": "_blank", "external": true, "icon": "Share" diff --git a/src/pages/cipp/logs/logentry.js b/src/pages/cipp/logs/logentry.js index 521026693fd8..a03397b3b85f 100644 --- a/src/pages/cipp/logs/logentry.js +++ b/src/pages/cipp/logs/logentry.js @@ -1,14 +1,46 @@ import { useRouter } from "next/router"; import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { ApiGetCall } from "../../../api/ApiCall"; -import { Button, SvgIcon, Box, Container, Chip } from "@mui/material"; +import { Box, Container, Chip, TextField, Card, CardHeader, CardContent } from "@mui/material"; import { Stack } from "@mui/system"; -import ArrowLeftIcon from "@mui/icons-material/ArrowLeft"; import { CippPropertyListCard } from "../../../components/CippCards/CippPropertyListCard"; import { CippInfoBar } from "../../../components/CippCards/CippInfoBar"; import CippFormSkeleton from "../../../components/CippFormPages/CippFormSkeleton"; import { getCippTranslation } from "../../../utils/get-cipp-translation"; +const formatRawError = (rawError) => { + if (rawError == null || rawError === "") return ""; + try { + if (typeof rawError === "object") { + return JSON.stringify(rawError, null, 2) || ""; + } + const asString = String(rawError).trim(); + if (!asString || asString === "null" || asString === "undefined" || asString === "Not available") { + return ""; + } + try { + return JSON.stringify(JSON.parse(asString), null, 2) || asString; + } catch { + return asString; + } + } catch { + return ""; + } +}; + +const formatLogDataValue = (value) => { + if (value == null || value === "") return "N/A"; + try { + if (typeof value === "object") { + return JSON.stringify(value, null, 2) || "N/A"; + } + const asString = String(value); + return asString || "N/A"; + } catch { + return "Unable to display value"; + } +}; + const Page = () => { const router = useRouter(); const { logentry, dateFilter } = router.query; @@ -23,24 +55,23 @@ const Page = () => { waiting: !!logentry, }); - const handleBackClick = () => { - router.push("/cipp/logs"); - }; - // Get the log data from array const logData = logRequest.data?.[0]; // Top info bar data like dashboard const logInfo = logData ? [ - { name: "Log ID", data: logData.RowKey }, - { name: "Date & Time", data: new Date(logData.DateTime).toLocaleString() }, - { name: "API", data: logData.API }, + { name: "Log ID", data: logData.RowKey ?? "N/A" }, + { + name: "Date & Time", + data: logData.DateTime ? new Date(logData.DateTime).toLocaleString() : "N/A", + }, + { name: "API", data: logData.API ?? "N/A" }, { name: "Severity", data: ( { // Main log properties const propertyItems = logData ? [ - { label: "Tenant", value: logData.Tenant }, - { label: "User", value: logData.User }, - { label: "Message", value: logData.Message }, - { label: "Tenant ID", value: logData.TenantID }, + { label: "Tenant", value: logData.Tenant ?? "N/A" }, + { label: "User", value: logData.User ?? "N/A" }, + { label: "Message", value: logData.Message ?? "N/A" }, + { label: "Tenant ID", value: logData.TenantID ?? "N/A" }, { label: "App ID", value: logData.AppId || "None" }, { label: "IP Address", value: logData.IP || "None" }, ] : []; - // LogData properties + const formattedRawError = formatRawError(logData?.LogData?.RawError); + + // LogData properties (RawError is shown separately) const logDataItems = - logData?.LogData && typeof logData.LogData === "object" - ? Object.entries(logData.LogData).map(([key, value]) => ({ - label: key, - value: typeof value === "object" ? JSON.stringify(value, null, 2) : String(value), + logData?.LogData && typeof logData.LogData === "object" && !Array.isArray(logData.LogData) + ? Object.entries(logData.LogData) + .filter(([key, value]) => key !== "RawError" && value != null && value !== "") + .map(([key, value]) => ({ + label: key, + value: formatLogDataValue(value), + })) + : []; + + const standardItems = + logData?.Standard && typeof logData.Standard === "object" && !Array.isArray(logData.Standard) + ? Object.entries(logData.Standard).map(([key, value]) => ({ + label: getCippTranslation(key), + value: value ?? "N/A", })) : []; @@ -114,13 +157,33 @@ const Page = () => { showDivider={false} /> )} - {logData?.Standard?.Standard && ( + {formattedRawError ? ( + + + + + + + ) : null} + {standardItems.length > 0 && ( ({ - label: getCippTranslation(key), - value: value ?? "N/A", - }))} + propertyItems={standardItems} isFetching={logRequest.isLoading} layout="multiple" showDivider={false} diff --git a/src/pages/cipp/settings/permissions.js b/src/pages/cipp/settings/permissions.js index 29f7ceb452e1..4393a3e602e7 100644 --- a/src/pages/cipp/settings/permissions.js +++ b/src/pages/cipp/settings/permissions.js @@ -1,4 +1,4 @@ -import { Container } from "@mui/material"; +import { Alert, Container } from "@mui/material"; import { Grid } from "@mui/system"; import { TabbedLayout } from "../../../layouts/TabbedLayout"; import { Layout as DashboardLayout } from "../../../layouts/index.js"; @@ -6,10 +6,30 @@ import tabOptions from "./tabOptions"; import CippPermissionCheck from "../../../components/CippSettings/CippPermissionCheck"; import { CippPermissionReport } from "../../../components/CippSettings/CippPermissionReport"; import { useState } from "react"; +import { ApiGetCall } from "../../../api/ApiCall"; const Page = () => { const [importReport, setImportReport] = useState(false); + // Same signal the Add Tenant wizard uses to decide whether partner-only flows apply. + // No tenantFilter means the backend defaults to the CIPP host tenant. + const organization = ApiGetCall({ + url: "/api/ListGraphRequest", + queryKey: "ListGraphRequest-organization-partnerTenantType", + data: { + Endpoint: "organization", + $select: "partnerTenantType,displayName", + }, + }); + + const partnerTenantType = organization.data?.Results?.[0]?.partnerTenantType; + const partnerCheckComplete = organization.isSuccess || organization.isError; + const isPartner = organization.isSuccess && Boolean(partnerTenantType); + + // Keep the GDAP check visible until we know it does not apply, and always show it when an + // imported report contains GDAP data. + const showGdapCheck = !partnerCheckComplete || isPartner || Boolean(importReport?.GDAP); + return ( @@ -20,7 +40,15 @@ const Page = () => { - + {showGdapCheck ? ( + + ) : ( + + GDAP checks do not apply to this environment. Your tenants are added directly rather + than through Microsoft Partner Center relationships, so access is verified per tenant + in the Tenants check below. + + )} diff --git a/src/pages/cipp/settings/tenants.js b/src/pages/cipp/settings/tenants.js index d680c482227d..77491217563d 100644 --- a/src/pages/cipp/settings/tenants.js +++ b/src/pages/cipp/settings/tenants.js @@ -14,6 +14,7 @@ import { Delete, Add, Refresh, + VpnKey, } from "@mui/icons-material"; import cacheTypes from "../../../data/CIPPDBCacheTypes.json"; @@ -51,6 +52,14 @@ const Page = () => { data: { tenantFilter: "customerId" }, confirmText: "Are you sure you want to refresh the CPV permissions for [displayName]?", multiPost: false, + condition: (row) => + row.displayName !== "*Partner Tenant" && row.delegatedPrivilegeStatus !== "directTenant", + }, + { + label: "Re-authenticate Connection", + link: "/onboardingv2?selectedOption=AddTenant&tenantType=Direct", + icon: , + condition: (row) => row.delegatedPrivilegeStatus === "directTenant", }, { label: "Reset CPV Permissions", @@ -118,6 +127,8 @@ const Page = () => { "displayName", "defaultDomainName", "delegatedPrivilegeStatus", + "directTenantUserPrincipalName", + "directTenantAuthDate", "Excluded", "ExcludeDate", "ExcludeUser", @@ -145,6 +156,16 @@ const Page = () => { value: [{ id: "Excluded", value: "Yes" }], type: "column", }, + { + filterName: "Direct tenants", + value: [{ id: "delegatedPrivilegeStatus", value: "Direct Tenant" }], + type: "column", + }, + { + filterName: "GDAP tenants", + value: [{ id: "delegatedPrivilegeStatus", value: "GDAP Tenant" }], + type: "column", + }, ]; return ( diff --git a/src/pages/dashboardv1.js b/src/pages/dashboardv1.js index 8e45642518cd..268c9f123799 100644 --- a/src/pages/dashboardv1.js +++ b/src/pages/dashboardv1.js @@ -258,7 +258,12 @@ const Page = () => { const menuItems = filteredPortals.map((portal) => ({ label: portal.label, target: "_blank", - link: portal.url.replace(portal.variable, tenantLookup?.[portal.variable]), + // A portal with a `field` has a URL the backend resolved for us (SharePoint's host cannot be + // derived from the tenant). Use it when it's there, otherwise fall back to the templated URL. + link: + portal.field && tenantLookup?.[portal.field] + ? tenantLookup[portal.field] + : portal.url.replace(portal.variable, tenantLookup?.[portal.variable]), icon: portal.icon, })); setPortalMenuItems(menuItems); diff --git a/src/pages/dashboardv2/index.js b/src/pages/dashboardv2/index.js index b2934a05964f..eb03b7618038 100644 --- a/src/pages/dashboardv2/index.js +++ b/src/pages/dashboardv2/index.js @@ -169,7 +169,12 @@ const Page = () => { const menuItems = filteredPortals.map((portal) => ({ label: portal.label, target: '_blank', - link: portal.url.replace(portal.variable, tenantLookup?.[portal.variable]), + // A portal with a `field` has a URL the backend resolved for us (SharePoint's host cannot be + // derived from the tenant). Use it when it's there, otherwise fall back to the templated URL. + link: + portal.field && tenantLookup?.[portal.field] + ? tenantLookup[portal.field] + : portal.url.replace(portal.variable, tenantLookup?.[portal.variable]), icon: portal.icon, })) setPortalMenuItems(menuItems) diff --git a/src/pages/endpoint/MEM/compare-policies/index.js b/src/pages/endpoint/MEM/compare-policies/index.js index 809b294c8c77..64cd745bd4b0 100644 --- a/src/pages/endpoint/MEM/compare-policies/index.js +++ b/src/pages/endpoint/MEM/compare-policies/index.js @@ -11,19 +11,11 @@ import { Card, CardContent, CardHeader, - Typography, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, Alert, Stack, - Chip, Skeleton, } from "@mui/material"; +import { CippPolicyDiffTable } from "../../../../components/CippComponents/CippPolicyDiffTable"; import { CompareArrows as CompareArrowsIcon, CheckCircle as CheckCircleIcon, @@ -226,56 +218,6 @@ const SourceSelector = ({ prefix, formControl, label }) => { ); }; -const hasValue = (val) => val !== null && val !== undefined && val !== ""; - -const getDiffStatus = (row) => { - const a = hasValue(row.ExpectedValue); - const b = hasValue(row.ReceivedValue); - if (a && b) return "different"; - if (a) return "onlyA"; - if (b) return "onlyB"; - return "equal"; -}; - -const diffChipProps = { - different: { label: "Different", color: "error" }, - onlyA: { label: "Only in A", color: "warning" }, - onlyB: { label: "Only in B", color: "info" }, - equal: { label: "Equal", color: "success" }, -}; - -const DiffStatusChip = ({ row }) => { - const props = diffChipProps[getDiffStatus(row)]; - return ; -}; - -const diffRowColors = { - different: { dark: "rgba(244, 67, 54, 0.08)", light: "rgba(244, 67, 54, 0.04)" }, - onlyA: { dark: "rgba(255, 152, 0, 0.08)", light: "rgba(255, 152, 0, 0.04)" }, - onlyB: { dark: "rgba(33, 150, 243, 0.08)", light: "rgba(33, 150, 243, 0.04)" }, - equal: { dark: "transparent", light: "transparent" }, -}; - -const getRowColor = (row, theme) => { - const colors = diffRowColors[getDiffStatus(row)]; - return theme.palette.mode === "dark" ? colors.dark : colors.light; -}; - -const formatValue = (val) => { - if (val === null || val === undefined) return N/A; - if (typeof val === "object") { - return ( - - {JSON.stringify(val, null, 2)} - - ); - } - return String(val); -}; - const Page = () => { const formControl = useForm({ mode: "onChange", @@ -419,35 +361,7 @@ const Page = () => { {!results.identical && comparisonRows.length > 0 && ( - - - - - Property - Source A - Source B - Status - - - - {comparisonRows.map((row, index) => ( - ({ - backgroundColor: getRowColor(row, theme), - })} - > - {row.Property} - {formatValue(row.ExpectedValue)} - {formatValue(row.ReceivedValue)} - - - - - ))} - -
-
+ )} { creatable: false, api: { url: "/api/ListGraphRequest", + queryKey: "TeamsVoiceAssignableUsers", dataKey: "Results", data: { Endpoint: "users", @@ -80,7 +81,16 @@ const Page = () => { label: "Emergency Location", api: { url: "/api/ListTeamsLisLocation", - labelField: "Description", + queryKey: "TeamsLisLocations", + // Description is optional on a location, so fall back to the place name and + // then the street address rather than rendering "No label found". + labelField: (location) => + location.Description || + location.Location || + [location.HouseNumber, location.StreetName, location.City] + .filter(Boolean) + .join(" ") || + location.LocationId, valueField: "LocationId", }, }, @@ -95,6 +105,7 @@ const Page = () => { "AcquiredCapabilities", "AssignmentStatus", "AssignedTo", + "EmergencyLocation", ], actions: actions, }; @@ -109,10 +120,11 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={[ ...reportDB.cacheColumns, - "AssignedTo", + "AssignedTo.userPrincipalName", "TelephoneNumber", "AssignmentStatus", "NumberType", + "EmergencyLocation", "AcquiredCapabilities", "IsoCountryCode", "PlaceName", diff --git a/src/pages/tenant/manage/applied-standards.js b/src/pages/tenant/manage/applied-standards.js index 3a04c540961a..35988e99e142 100644 --- a/src/pages/tenant/manage/applied-standards.js +++ b/src/pages/tenant/manage/applied-standards.js @@ -37,6 +37,7 @@ import { Schedule, Check, Warning, + CompareArrows, } from '@mui/icons-material' import { getStandards } from '../../../utils/standards-data' import { CippApiDialog } from '../../../components/CippComponents/CippApiDialog' @@ -54,6 +55,14 @@ import tabOptions from './tabOptions.json' import { createDriftManagementActions } from './driftManagementActions' import { CippApiLogsDrawer } from '../../../components/CippComponents/CippApiLogsDrawer' import { CippHead } from '../../../components/CippComponents/CippHead' +import { CippPolicyCompareDialog } from '../../../components/CippComponents/CippPolicyCompareDialog' + +// Only Intune template standards can be compared live against their baseline. The standard records +// compliance as a boolean and discards the diff, so it has to be recomputed on demand. +const getCompareTemplateGuid = (standardId) => + standardId?.startsWith('standards.IntuneTemplate.') + ? standardId.substring('standards.IntuneTemplate.'.length) + : null const Page = () => { const router = useRouter() @@ -72,6 +81,7 @@ const Page = () => { const [filter, setFilter] = useState('all') const [searchQuery, setSearchQuery] = useState('') const [filterMenuAnchor, setFilterMenuAnchor] = useState(null) + const [compareTarget, setCompareTarget] = useState(null) const templateDetails = ApiGetCall({ url: `/api/listStandardTemplates`, @@ -2113,6 +2123,22 @@ const Page = () => { + {getCompareTemplateGuid(standard.standardId) && ( + + )} @@ -3223,6 +3249,15 @@ const Page = () => { }} relatedQueryKeys={['ListStandardsCompare']} /> + + setCompareTarget(null)} + tenantFilter={currentTenant} + templateGuid={compareTarget?.templateGuid} + templateName={compareTarget?.templateName} + standardsTemplateId={templateId} + /> ) diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js index bf83a90f3516..f63a754bf0df 100644 --- a/src/pages/tenant/manage/drift.js +++ b/src/pages/tenant/manage/drift.js @@ -14,6 +14,7 @@ import { FactCheck, Search, Edit, + CompareArrows, } from '@mui/icons-material' import { Box, @@ -44,6 +45,7 @@ import { createDriftManagementActions } from './driftManagementActions' import { ExecutiveReportButton } from '../../../components/ExecutiveReportButton' import { CippAutoComplete } from '../../../components/CippComponents/CippAutocomplete' import CippFormComponent from '../../../components/CippComponents/CippFormComponent' +import { CippPolicyCompareDialog } from '../../../components/CippComponents/CippPolicyCompareDialog' const ManageDriftPage = () => { const router = useRouter() @@ -60,6 +62,7 @@ const ManageDriftPage = () => { const [searchQuery, setSearchQuery] = useState('') const [sortBy, setSortBy] = useState('name') const [selectedItems, setSelectedItems] = useState([]) + const [compareTarget, setCompareTarget] = useState(null) const filterForm = useForm({ defaultValues: { @@ -1466,101 +1469,80 @@ const ManageDriftPage = () => { setSelectedItems([]) }, [tenantFilter]) - // Add action buttons to each deviation item - const deviationItemsWithActions = actualDeviationItems.map((item) => { - return { - ...item, - cardLabelBoxActions: ( + // Only Intune template standards can be compared live against their baseline. The standard + // records compliance as a boolean and discards the diff, so it has to be recomputed on demand. + // Note the singular prefix: "IntuneTemplates." is a tenant-only policy, which has no + // baseline in the template and so nothing to compare against. + const getCompareTemplateGuid = (item) => { + const name = item?.standardName + if (!name) return null + const withoutPrefix = name.startsWith('standards.') ? name.substring('standards.'.length) : name + return withoutPrefix.startsWith('IntuneTemplate.') + ? withoutPrefix.substring('IntuneTemplate.'.length) + : null + } + + const buildCardActions = (item, menuKey) => { + const templateGuid = getCompareTemplateGuid(item) + return ( + + {templateGuid && ( + + )} - ), - } - }) + + ) + } + + // Add action buttons to each deviation item + const deviationItemsWithActions = actualDeviationItems.map((item) => ({ + ...item, + cardLabelBoxActions: buildCardActions(item, item.id), + })) // Add action buttons to accepted deviation items - const acceptedDeviationItemsWithActions = acceptedDeviationItems.map((item) => { - return { - ...item, - cardLabelBoxActions: ( - - ), - } - }) + const acceptedDeviationItemsWithActions = acceptedDeviationItems.map((item) => ({ + ...item, + cardLabelBoxActions: buildCardActions(item, `accepted-${item.id}`), + })) // Add action buttons to customer specific deviation items - const customerSpecificDeviationItemsWithActions = customerSpecificDeviationItems.map((item) => { - return { - ...item, - cardLabelBoxActions: ( - - ), - } - }) + const customerSpecificDeviationItemsWithActions = customerSpecificDeviationItems.map((item) => ({ + ...item, + cardLabelBoxActions: buildCardActions(item, `customer-${item.id}`), + })) // Add action buttons to denied deviation items const deniedDeviationItemsWithActions = deniedDeviationItems.map((item) => ({ ...item, - cardLabelBoxActions: ( - - ), + cardLabelBoxActions: buildCardActions(item, `denied-${item.id}`), })) // Add action buttons to compliant/aligned items so previously denied and now compliant entries // can be denied again or denied with remediation persistence. const alignedItemsWithActions = allAlignedItems.map((item) => ({ ...item, - cardLabelBoxActions: ( - - ), + cardLabelBoxActions: buildCardActions(item, `aligned-${item.id}`), })) // Combined list used to resolve selected item IDs back to their deviation data @@ -2165,6 +2147,15 @@ const ManageDriftPage = () => { /> )} + setCompareTarget(null)} + tenantFilter={tenantFilter} + templateGuid={compareTarget?.templateGuid} + templateName={compareTarget?.templateName} + standardsTemplateId={templateId} + /> + {/* Render all Menu components outside of card structure */} {deviationItemsWithActions.map((item) => { return ( diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index c31b0eefc8ff..6bb56ee61e57 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -287,6 +287,8 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr 'requestDate', // App Consent Requests 'reviewedDate', // App Consent Requests 'GeneratedAt', // Report Builder + 'directTenantAuthDate', // Direct tenant service account + 'ServiceAccountLastAuth', // Direct tenant service account ] const matchDateTime = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/