Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cipp",
"version": "10.7.0",
"version": "10.7.1",
"author": "CIPP Contributors",
"homepage": "https://cipp.app/",
"bugs": {
Expand Down
2 changes: 1 addition & 1 deletion public/version.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"version": "10.7.0"
"version": "10.7.1"
}
174 changes: 174 additions & 0 deletions src/components/CippComponents/CippPolicyCompareDialog.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog fullWidth maxWidth="lg" open={open} onClose={handleClose}>
<DialogTitle>Compare to baseline{templateName ? ` - ${templateName}` : ''}</DialogTitle>
<DialogContent dividers>
{compareApi.isPending && (
<Stack spacing={2}>
<Skeleton variant="text" width="40%" />
<Skeleton variant="rectangular" height={200} />
</Stack>
)}

{errorMessage && (
<Alert severity="error" icon={<ErrorIcon />}>
{errorMessage}
</Alert>
)}

{results && (
<Stack spacing={3}>
{results.policyMissing ? (
<Alert severity="warning" icon={<PolicyOffIcon />}>
<AlertTitle>Not deployed to {tenantFilter}</AlertTitle>
<Typography variant="body2" sx={{ mb: 1 }}>
No Intune policy named{' '}
<Box component="strong">
{results.missingPolicyName || templateName || 'this policy'}
</Box>{' '}
exists in this tenant, so there is nothing to compare against. This is why the
standard reports it as non-compliant.
</Typography>
<Typography variant="body2">
{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.'}
</Typography>
</Alert>
) : (
<Alert
severity={results.identical ? 'success' : 'warning'}
icon={results.identical ? <CheckCircleIcon /> : <CompareArrowsIcon />}
>
{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.`}
</Alert>
)}

{results.matchType === 'fuzzy' && (
<Alert severity="info" icon={<InfoIcon />}>
<AlertTitle>Matched by similar name</AlertTitle>
No policy exactly named{' '}
<Box component="strong">{templateName || 'the template'}</Box> exists in this tenant,
so this compares against{' '}
<Box component="strong">{results.matchedName}</Box> - the policy the standard&apos;s
fuzzy match setting would overwrite on the next remediation.
</Alert>
)}

{!results.identical && comparisonRows.length > 0 && (
<CippPolicyDiffTable rows={comparisonRows} labelA="Baseline" labelB="Tenant" />
)}

{results.sourceAData && (
<CippJsonView
object={results.sourceAData}
type="intune"
title={
results.policyMissing
? `Baseline template, not yet in the tenant - ${results.sourceALabel}`
: `Baseline template - ${results.sourceALabel}`
}
/>
)}

{results.sourceBData && (
<CippJsonView
object={results.sourceBData}
type="intune"
title={`Tenant policy - ${results.sourceBLabel}`}
/>
)}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>
</DialogActions>
</Dialog>
)
}

export default CippPolicyCompareDialog
112 changes: 112 additions & 0 deletions src/components/CippComponents/CippPolicyDiffTable.jsx
Original file line number Diff line number Diff line change
@@ -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 <Typography color="text.disabled">N/A</Typography>
if (typeof val === 'object') {
return (
<Box
component="pre"
sx={{ m: 0, fontSize: '0.8rem', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{JSON.stringify(val, null, 2)}
</Box>
)
}
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 (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>Property</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>{labelA}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>{labelB}</TableCell>
<TableCell sx={{ fontWeight: 'bold', width: 140 }}>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => {
const status = getDiffStatus(row)
return (
<TableRow key={index} sx={(theme) => ({ backgroundColor: getRowColor(row, theme) })}>
<TableCell sx={{ fontWeight: 500 }}>{row.Property}</TableCell>
<TableCell>{formatDiffValue(row.ExpectedValue)}</TableCell>
<TableCell>{formatDiffValue(row.ReceivedValue)}</TableCell>
<TableCell>
<Chip
label={diffChipLabels[status]}
size="small"
color={diffStatusColors[status]}
variant="outlined"
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>
)
}

export default CippPolicyDiffTable
38 changes: 27 additions & 11 deletions src/components/CippComponents/CippTenantSelector.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -227,6 +232,7 @@ export const CippTenantSelector = React.forwardRef((props, ref) => {
displayName: matchingTenant.displayName,
customerId: matchingTenant.customerId,
initialDomainName: matchingTenant.initialDomainName,
sharepointAdminUrl: matchingTenant.SharepointAdminUrl,
},
});
}
Expand Down Expand Up @@ -287,6 +293,7 @@ export const CippTenantSelector = React.forwardRef((props, ref) => {
displayName: matchingTenant.displayName,
customerId: matchingTenant.customerId,
initialDomainName: matchingTenant.initialDomainName,
sharepointAdminUrl: matchingTenant.SharepointAdminUrl,
},
}
: {
Expand Down Expand Up @@ -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 || ""}
Expand Down
4 changes: 3 additions & 1 deletion src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export function useCippGDAPTrace() {
icon: <Security />,
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",
}),
[]
);
Expand Down
6 changes: 4 additions & 2 deletions src/components/CippSettings/CippPermissionReport.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Loading
Loading