+
+
+
+
+ {columns.map((ms, i) => (
+
+ {i % headerStep === 0 ? fmtTime(ms) : ""}
+
+ ))}
+
+ {pageRows.map((t) => (
+
+
+ {t.name.replace(/\.onmicrosoft\.com$/, "")}
+
+ {t.states.map((s, i) => {
+ const empty = s === "Empty";
+ const gap = s === "Gap";
+ return (
+
+ !empty &&
+ onCellClick &&
+ onCellClick({
+ tenant: t.name,
+ startMs: columns[i],
+ endMs: t.cells[i].length ? toMs(t.cells[i][0].WindowEnd) : null,
+ })
+ }
+ sx={{
+ height: 20,
+ borderRadius: "3px",
+ cursor: empty ? "default" : "pointer",
+ bgcolor: gap || empty ? "transparent" : colors[s],
+ border: gap
+ ? `1px dashed ${theme.palette.error.main}`
+ : empty
+ ? `1px solid ${theme.palette.divider}`
+ : "none",
+ opacity: empty ? 0.35 : 1,
+ transition: "transform .1s ease-in-out",
+ "&:hover": empty ? {} : { transform: "scale(1.18)", zIndex: 1 },
+ }}
+ />
+ );
+ })}
+
+ ))}
+
+
+ setPage(p)}
+ rowsPerPage={rowsPerPage}
+ onRowsPerPageChange={(e) => {
+ setRowsPerPage(parseInt(e.target.value, 10));
+ setPage(0);
+ }}
+ rowsPerPageOptions={[25, 50, 100, 250, { label: "All", value: -1 }]}
+ labelRowsPerPage="Tenants per page"
+ />
+
+ );
+};
+
+export default CippCoverageHeatmap;
diff --git a/src/components/CippComponents/CippDateRangeFilter.jsx b/src/components/CippComponents/CippDateRangeFilter.jsx
new file mode 100644
index 000000000000..e9123f568dbd
--- /dev/null
+++ b/src/components/CippComponents/CippDateRangeFilter.jsx
@@ -0,0 +1,135 @@
+import { useState } from "react";
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Button,
+ Typography,
+} from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import { Grid } from "@mui/system";
+import { useForm } from "react-hook-form";
+import CippFormComponent from "./CippFormComponent";
+
+/**
+ * Reusable relative / start-end date range filter (the one used on the Saved Logs page).
+ * Calls onApply({ RelativeTime, StartDate, EndDate }) when the user applies the filter.
+ * RelativeTime is formatted like "48h" / "7d"; StartDate/EndDate come from the date pickers.
+ */
+export const CippDateRangeFilter = ({
+ onApply,
+ defaultTime = 7,
+ defaultInterval = { label: "Days", value: "d" },
+ title = "Search Options",
+}) => {
+ const formControl = useForm({
+ mode: "onChange",
+ defaultValues: {
+ dateFilter: "relative",
+ Time: defaultTime,
+ Interval: defaultInterval,
+ },
+ });
+
+ const [expanded, setExpanded] = useState(false);
+
+ const onSubmit = (data) => {
+ if (data.dateFilter === "relative") {
+ onApply?.({ RelativeTime: `${data.Time}${data.Interval.value}`, StartDate: null, EndDate: null });
+ } else if (data.dateFilter === "startEnd") {
+ onApply?.({ RelativeTime: null, StartDate: data.startDate, EndDate: data.endDate });
+ }
+ };
+
+ return (
+ setExpanded(!expanded)}>
+ }>
+ {title}
+
+
+
+
+
+ );
+};
+
+export default CippDateRangeFilter;
diff --git a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx
index c03c976f9f94..9ca5af4741e1 100644
--- a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx
+++ b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx
@@ -67,10 +67,13 @@ const MODE_CONFIG = {
"Tooltip": "Confidential data, do not share externally",
"Comment": "Internal-only confidential classification",
"ContentType": "File, Email",
+ "ApplyContentMarkingHeaderEnabled": true,
+ "ApplyContentMarkingHeaderText": "Confidential - Internal Use Only",
+ "ApplyContentMarkingHeaderFontColor": "#FF0000",
"EncryptionEnabled": true,
- "EncryptionProtectionType": "Template",
- "ContentMarkingHeaderEnabled": true,
- "ContentMarkingHeaderText": "Confidential - Internal Use Only",
+ "EncryptionProtectionType": "UserDefined",
+ "EncryptionPromptUser": true,
+ "EncryptionDoNotForward": true,
"PolicyParams": {
"Name": "Confidential Label Policy",
"ExchangeLocation": "All",
diff --git a/src/components/CippComponents/CippDevOptions.jsx b/src/components/CippComponents/CippDevOptions.jsx
index 7bddbbbc126a..5d1951eb3d9e 100644
--- a/src/components/CippComponents/CippDevOptions.jsx
+++ b/src/components/CippComponents/CippDevOptions.jsx
@@ -1,6 +1,15 @@
import { useSettings } from "../../hooks/use-settings";
-import { Button, Card, CardHeader, Divider, CardContent, SvgIcon } from "@mui/material";
-import { CodeBracketIcon } from "@heroicons/react/24/outline";
+import {
+ Button,
+ Card,
+ CardHeader,
+ Divider,
+ CardContent,
+ Stack,
+ SvgIcon,
+ Typography,
+} from "@mui/material";
+import { CodeBracketIcon, BeakerIcon } from "@heroicons/react/24/outline";
export const CippDevOptions = () => {
const settings = useSettings();
@@ -11,22 +20,45 @@ export const CippDevOptions = () => {
});
};
+ const handleAdvancedToggle = () => {
+ settings.handleUpdate({
+ showAdvancedTools: !settings.showAdvancedTools,
+ });
+ };
+
return (
-
+
+
+
+
+
+ Advanced Views reveal diagnostic pages (such as audit-log Search Coverage) that are hidden
+ from day-to-day operations. This preference is per-user, stored in this browser.
+
);
diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx
index 628a9aa0dafc..d8b2f9d48443 100644
--- a/src/components/CippComponents/CippFormComponent.jsx
+++ b/src/components/CippComponents/CippFormComponent.jsx
@@ -25,6 +25,25 @@ import { CippDataTable } from "../CippTable/CippDataTable";
import React from "react";
import { CloudUpload } from "@mui/icons-material";
import { Stack } from "@mui/system";
+import countryList from "../../data/countryList";
+import languageList from "../../data/languageList";
+
+// ISO 3166-1 alpha-2 country/region codes (uppercase), used by the CountryCodeMultiSelect type.
+const countryCodeOptions = countryList
+ .map((c) => ({ label: `${c.Name} (${c.Code})`, value: c.Code }))
+ .sort((a, b) => a.label.localeCompare(b.label));
+
+// ISO 639-1 alpha-2 language codes (lowercase), used by the LanguageCodeMultiSelect type.
+// Derived from the locale tags in languageList.json, deduplicated to the two-letter primary subtag (e.g. "en-US" -> "en").
+const languageCodeOptions = Object.values(
+ languageList.reduce((acc, entry) => {
+ const code = entry.tag?.split("-")[0]?.toLowerCase();
+ if (code && code.length === 2 && !acc[code]) {
+ acc[code] = { label: `${entry.language} (${code})`, value: code };
+ }
+ return acc;
+ }, {}),
+).sort((a, b) => a.label.localeCompare(b.label));
// The tiptap / prosemirror / mui-tiptap editor tree is large and only used by `richText` fields.
// Load it on demand via next/dynamic so it is code-split into an async chunk instead of being
@@ -87,6 +106,59 @@ export const CippFormComponent = (props) => {
}
};
+ // Shared renderer for autoComplete-backed fields (autoComplete + the ISO-code multiselects).
+ const renderAutoCompleteField = (autoCompleteProps) => {
+ // Resolve options if it's a function
+ const resolvedOptions =
+ typeof autoCompleteProps.options === "function"
+ ? autoCompleteProps.options(row)
+ : autoCompleteProps.options;
+
+ // Wrap validate function to pass row as third parameter
+ const resolvedValidators = validators
+ ? {
+ ...validators,
+ validate:
+ typeof validators.validate === "function"
+ ? (value, formValues) => validators.validate(value, formValues, row)
+ : validators.validate,
+ }
+ : validators;
+
+ return (
+
+ (
+ field.onChange(value)}
+ onBlur={field.onBlur}
+ />
+ )}
+ />
+
+ {get(errors, convertedName, {})?.message && (
+
+ {get(errors, convertedName, {})?.message}
+
+ )}
+ {helperText && (
+
+ {helperText}
+
+ )}
+
+ );
+ };
+
switch (type) {
case "heading":
return (
@@ -434,55 +506,26 @@ export const CippFormComponent = (props) => {
>
);
- case "autoComplete": {
- // Resolve options if it's a function
- const resolvedOptions =
- typeof other.options === "function" ? other.options(row) : other.options;
-
- // Wrap validate function to pass row as third parameter
- const resolvedValidators = validators
- ? {
- ...validators,
- validate:
- typeof validators.validate === "function"
- ? (value, formValues) => validators.validate(value, formValues, row)
- : validators.validate,
- }
- : validators;
+ case "autoComplete":
+ return renderAutoCompleteField(other);
- return (
-
- (
- field.onChange(value)}
- onBlur={field.onBlur}
- />
- )}
- />
+ // ISO 3166-1 alpha-2 region/country code multiselect (e.g. Spam Filter RegionBlockList).
+ case "CountryCodeMultiSelect":
+ return renderAutoCompleteField({
+ ...other,
+ options: countryCodeOptions,
+ multiple: true,
+ creatable: false,
+ });
- {get(errors, convertedName, {})?.message && (
-
- {get(errors, convertedName, {})?.message}
-
- )}
- {helperText && (
-
- {helperText}
-
- )}
-
- );
- }
+ // ISO 639-1 alpha-2 language code multiselect (e.g. Spam Filter LanguageBlockList).
+ case "LanguageCodeMultiSelect":
+ return renderAutoCompleteField({
+ ...other,
+ options: languageCodeOptions,
+ multiple: true,
+ creatable: false,
+ });
case "richText": {
return (
diff --git a/src/components/CippComponents/CippFormUserAndGroupSelector.jsx b/src/components/CippComponents/CippFormUserAndGroupSelector.jsx
index b2fef52aa1a5..ddb0992eeab8 100644
--- a/src/components/CippComponents/CippFormUserAndGroupSelector.jsx
+++ b/src/components/CippComponents/CippFormUserAndGroupSelector.jsx
@@ -30,12 +30,13 @@ export const CippFormUserAndGroupSelector = ({
url: "/api/ListUsersAndGroups",
dataKey: "Results",
labelField: (option) => {
- // If it's a group (no userPrincipalName), just show displayName
- if (!option.userPrincipalName) {
- return `${option.displayName}`;
- }
- // If it's a user, show displayName and userPrincipalName
- return `${option.displayName} (${option.userPrincipalName})`;
+ if (option.userPrincipalName) return `${option.displayName} (${option.userPrincipalName})`;
+ const groupType = option.mailEnabled && !option.securityEnabled
+ ? "Distribution Group"
+ : option.mailEnabled && option.securityEnabled
+ ? "Mail-Enabled Security Group"
+ : "Security Group";
+ return `${option.displayName} (${groupType})`;
},
valueField: valueField ? valueField : "id",
queryKey: `ListUsersAndGroups-${
@@ -52,13 +53,6 @@ export const CippFormUserAndGroupSelector = ({
},
showRefresh: showRefresh,
}}
- groupBy={(option) => {
- // Group by type - Users or Groups
- if (option["@odata.type"] === "#microsoft.graph.group") {
- return "Groups";
- }
- return "Users";
- }}
creatable={false}
{...other}
/>
diff --git a/src/components/CippComponents/CippIntunePolicyActions.jsx b/src/components/CippComponents/CippIntunePolicyActions.jsx
index fb7363ce14db..def812399959 100644
--- a/src/components/CippComponents/CippIntunePolicyActions.jsx
+++ b/src/components/CippComponents/CippIntunePolicyActions.jsx
@@ -18,6 +18,11 @@ const assignmentFilterTypeOptions = [
{ label: 'Exclude - Apply policy to devices NOT matching filter', value: 'exclude' },
]
+const assignmentDirectionOptions = [
+ { label: 'Include these group(s)', value: 'include' },
+ { label: 'Exclude these group(s)', value: 'exclude' },
+]
+
/**
* Get assignment actions for Intune policies
* @param {string} tenant - The tenant filter
@@ -43,16 +48,57 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
templateData = null,
} = options
- const getAssignmentFields = () => [
+ // Group picker (by ID) reused for both include and exclude selection
+ const getGroupPickerField = (name, label, required) => ({
+ type: 'autoComplete',
+ name,
+ label,
+ multiple: true,
+ creatable: false,
+ allowResubmit: true,
+ ...(required && { validators: { required: 'Please select at least one group' } }),
+ api: {
+ url: '/api/ListGraphRequest',
+ dataKey: 'Results',
+ queryKey: `ListPolicyAssignmentGroups-${tenant}`,
+ labelField: (group) => (group.id ? `${group.displayName} (${group.id})` : group.displayName),
+ valueField: 'id',
+ addedField: {
+ description: 'description',
+ },
+ data: {
+ Endpoint: 'groups',
+ manualPagination: true,
+ $select: 'id,displayName,description',
+ $orderby: 'displayName',
+ $top: 999,
+ $count: true,
+ },
+ },
+ })
+
+ // Assignment mode + optional device filter, shared by every assign action.
+ const getOptionsAndFilterFields = (modeHelperText) => [
+ {
+ type: 'heading',
+ label: 'Assignment options',
+ },
{
type: 'radio',
name: 'assignmentMode',
label: 'Assignment mode',
options: assignmentModeOptions,
defaultValue: 'replace',
+ // Re-validate the Custom Group picker (no-op for broad actions, which have no groupTargets).
+ validators: { deps: ['groupTargets'] },
helperText:
+ modeHelperText ||
'Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups.',
},
+ {
+ type: 'heading',
+ label: 'Device filter (optional)',
+ },
{
type: 'autoComplete',
name: 'assignmentFilter',
@@ -73,12 +119,56 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
options: assignmentFilterTypeOptions,
defaultValue: 'include',
helperText: 'Choose whether to include or exclude devices matching the filter.',
+ condition: { field: 'assignmentFilter', compareType: 'hasValue', clearOnHide: false },
},
+ ]
+
+ // All Users / All Devices / Globally: fixed target, with an optional exclude-groups picker.
+ const getBroadAssignFields = () => [
{
- type: 'textField',
- name: 'excludeGroup',
- label: 'Exclude Group Names separated by comma. Wildcards (*) are allowed',
+ type: 'heading',
+ label: 'Exclude groups (optional)',
},
+ getGroupPickerField('excludeGroupTargets', 'Exclude group(s)', false),
+ ...getOptionsAndFilterFields(),
+ ]
+
+ // Custom Group: one picker + a radio choosing whether those groups are included or excluded.
+ const getCustomGroupFields = () => [
+ {
+ type: 'heading',
+ label: 'Target groups',
+ },
+ {
+ ...getGroupPickerField('groupTargets', 'Group(s)', false),
+ helperText: 'Leave empty with Exclude + Replace to remove all exclusions (keeps includes).',
+ validators: {
+ // Required, except Exclude + Replace where an empty selection clears all exclusions.
+ validate: (value, formValues) => {
+ if (
+ formValues?.assignmentDirection === 'exclude' &&
+ (formValues?.assignmentMode || 'replace') === 'replace'
+ ) {
+ return true
+ }
+ return (Array.isArray(value) && value.length > 0) || 'Please select at least one group'
+ },
+ },
+ },
+ {
+ type: 'radio',
+ name: 'assignmentDirection',
+ label: 'Assignment direction',
+ options: assignmentDirectionOptions,
+ defaultValue: 'include',
+ // Re-validate the picker so the empty-allowed rule updates when direction changes.
+ validators: { deps: ['groupTargets'] },
+ helperText:
+ 'Include assigns to these groups; Exclude excludes them. Replace updates only this direction and keeps the other (and All Users/All Devices) intact.',
+ },
+ ...getOptionsAndFilterFields(
+ 'Replace updates only the selected direction and keeps the other direction plus All Users/All Devices. Append adds the selected groups to existing assignments.'
+ ),
]
const getCustomDataFormatter = (assignTo) => (row, action, formData) => {
@@ -90,7 +180,8 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
...(platformType && { platformType }),
AssignTo: assignTo,
assignmentMode: formData?.assignmentMode || 'replace',
- excludeGroup: formData?.excludeGroup || null,
+ ExcludeGroupIds: (formData?.excludeGroupTargets || []).map((g) => g.value).filter(Boolean),
+ ExcludeGroupNames: (formData?.excludeGroupTargets || []).map((g) => g.label).filter(Boolean),
AssignmentFilterName: formData?.assignmentFilter?.value || null,
AssignmentFilterType: formData?.assignmentFilter?.value
? formData?.assignmentFilterType || 'include'
@@ -101,15 +192,20 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
const getCustomDataFormatterForGroups = () => (row, action, formData) => {
const rows = Array.isArray(row) ? row : [row]
const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []
+ const isExclude = formData?.assignmentDirection === 'exclude'
+ const ids = selectedGroups.map((group) => group.value).filter(Boolean)
+ const names = selectedGroups.map((group) => group.label).filter(Boolean)
return rows.map((item) => ({
tenantFilter: tenant === 'AllTenants' && item?.Tenant ? item.Tenant : tenant,
ID: item?.id,
type: item?.URLName || policyType,
...(platformType && { platformType }),
- GroupIds: selectedGroups.map((group) => group.value).filter(Boolean),
- GroupNames: selectedGroups.map((group) => group.label).filter(Boolean),
+ GroupIds: isExclude ? [] : ids,
+ GroupNames: isExclude ? [] : names,
+ ExcludeGroupIds: isExclude ? ids : [],
+ ExcludeGroupNames: isExclude ? names : [],
+ assignmentDirection: formData?.assignmentDirection || 'include',
assignmentMode: formData?.assignmentMode || 'replace',
- excludeGroup: formData?.excludeGroup || null,
AssignmentFilterName: formData?.assignmentFilter?.value || null,
AssignmentFilterType: formData?.assignmentFilter?.value
? formData?.assignmentFilterType || 'include'
@@ -210,6 +306,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
label: 'Assign to All Users',
type: 'POST',
url: '/api/ExecAssignPolicy',
+ allowResubmit: true,
data: {
AssignTo: 'allLicensedUsers',
ID: 'id',
@@ -217,7 +314,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
...(platformType && { platformType: '!deviceAppManagement' }),
},
multiPost: false,
- fields: getAssignmentFields(),
+ fields: getBroadAssignFields(),
customDataformatter: getCustomDataFormatter('allLicensedUsers'),
confirmText: 'Are you sure you want to assign "[displayName]" to all users?',
icon: ,
@@ -229,6 +326,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
label: 'Assign to All Devices',
type: 'POST',
url: '/api/ExecAssignPolicy',
+ allowResubmit: true,
data: {
AssignTo: 'AllDevices',
ID: 'id',
@@ -236,7 +334,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
...(platformType && { platformType: '!deviceAppManagement' }),
},
multiPost: false,
- fields: getAssignmentFields(),
+ fields: getBroadAssignFields(),
customDataformatter: getCustomDataFormatter('AllDevices'),
confirmText: 'Are you sure you want to assign "[displayName]" to all devices?',
icon: ,
@@ -248,6 +346,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
label: 'Assign Globally (All Users / All Devices)',
type: 'POST',
url: '/api/ExecAssignPolicy',
+ allowResubmit: true,
data: {
AssignTo: 'AllDevicesAndUsers',
ID: 'id',
@@ -255,7 +354,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
...(platformType && { platformType: '!deviceAppManagement' }),
},
multiPost: false,
- fields: getAssignmentFields(),
+ fields: getBroadAssignFields(),
customDataformatter: getCustomDataFormatter('AllDevicesAndUsers'),
confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?',
icon: ,
@@ -267,41 +366,12 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) =>
label: 'Assign to Custom Group',
type: 'POST',
url: '/api/ExecAssignPolicy',
+ allowResubmit: true,
icon: ,
color: 'info',
confirmText: 'Select the target groups for "[displayName]".',
multiPost: false,
- fields: [
- {
- type: 'autoComplete',
- name: 'groupTargets',
- label: 'Group(s)',
- multiple: true,
- creatable: false,
- allowResubmit: true,
- validators: { required: 'Please select at least one group' },
- api: {
- url: '/api/ListGraphRequest',
- dataKey: 'Results',
- queryKey: `ListPolicyAssignmentGroups-${tenant}`,
- labelField: (group) =>
- group.id ? `${group.displayName} (${group.id})` : group.displayName,
- valueField: 'id',
- addedField: {
- description: 'description',
- },
- data: {
- Endpoint: 'groups',
- manualPagination: true,
- $select: 'id,displayName,description',
- $orderby: 'displayName',
- $top: 999,
- $count: true,
- },
- },
- },
- ...getAssignmentFields(),
- ],
+ fields: getCustomGroupFields(),
customDataformatter: getCustomDataFormatterForGroups(),
})
diff --git a/src/components/CippComponents/CippMessageDeliveryInfo.jsx b/src/components/CippComponents/CippMessageDeliveryInfo.jsx
new file mode 100644
index 000000000000..032e2178f549
--- /dev/null
+++ b/src/components/CippComponents/CippMessageDeliveryInfo.jsx
@@ -0,0 +1,241 @@
+import React, { useMemo } from "react";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ Chip,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import { Box, Stack } from "@mui/system";
+
+// Split the raw email source into its header section and unfold RFC 5322 folded
+// headers (continuation lines that begin with whitespace belong to the previous
+// header).
+const getUnfoldedHeaderLines = (source) => {
+ if (!source || typeof source !== "string") return [];
+ const headerEnd = source.search(/\r?\n\r?\n/);
+ const headerSection = headerEnd === -1 ? source : source.slice(0, headerEnd);
+ const lines = headerSection.split(/\r?\n/);
+ const unfolded = [];
+ for (const line of lines) {
+ if (/^[ \t]/.test(line) && unfolded.length) {
+ unfolded[unfolded.length - 1] += " " + line.trim();
+ } else {
+ unfolded.push(line);
+ }
+ }
+ return unfolded;
+};
+
+const getHeaderValues = (lines, name) => {
+ const prefix = new RegExp(`^${name}\\s*:`, "i");
+ return lines.filter((l) => prefix.test(l)).map((l) => l.replace(prefix, "").trim());
+};
+
+const isValidDate = (d) => d instanceof Date && !isNaN(d);
+
+const parseHop = (raw) => {
+ const lastSemi = raw.lastIndexOf(";");
+ const dateStr = lastSemi !== -1 ? raw.slice(lastSemi + 1).trim() : null;
+ // Strip trailing parenthetical timezone notes like "(UTC)" that break Date().
+ const cleanedDate = dateStr ? dateStr.replace(/\s*\([^)]*\)\s*$/, "").trim() : null;
+ const date = cleanedDate ? new Date(cleanedDate) : null;
+ return {
+ from: raw.match(/\bfrom\s+([^\s;]+)/i)?.[1] ?? null,
+ by: raw.match(/\bby\s+([^\s;]+)/i)?.[1] ?? null,
+ with: raw.match(/\bwith\s+([^\s;()]+)/i)?.[1] ?? null,
+ for: raw.match(/\bfor\s+([^\s;>]+)>?/i)?.[1] ?? null,
+ date: isValidDate(date) ? date : null,
+ raw,
+ };
+};
+
+const formatDelay = (ms) => {
+ if (ms == null || isNaN(ms)) return "—";
+ if (ms < 0) ms = 0;
+ const s = Math.round(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rs = s % 60;
+ if (m < 60) return rs ? `${m}m ${rs}s` : `${m}m`;
+ const h = Math.floor(m / 60);
+ const rm = m % 60;
+ return rm ? `${h}h ${rm}m` : `${h}h`;
+};
+
+const authColor = (result) => {
+ switch ((result || "").toLowerCase()) {
+ case "pass":
+ return "success";
+ case "fail":
+ case "hardfail":
+ return "error";
+ case "softfail":
+ case "neutral":
+ case "none":
+ case "temperror":
+ case "permerror":
+ return "warning";
+ default:
+ return "default";
+ }
+};
+
+export const CippMessageDeliveryInfo = ({ emailSource }) => {
+ const { hops, totalMs, auth } = useMemo(() => {
+ const lines = getUnfoldedHeaderLines(emailSource);
+
+ // Received headers are prepended by each MTA, so the raw order is
+ // newest-first. Reverse to get chronological (oldest) order.
+ const received = getHeaderValues(lines, "Received")
+ .map(parseHop)
+ .reverse();
+
+ // Delay for hop i is the time between the previous hop and this one.
+ let total = null;
+ if (received.length > 1) {
+ const first = received[0].date;
+ const last = received[received.length - 1].date;
+ if (isValidDate(first) && isValidDate(last)) total = last - first;
+ }
+ for (let i = 0; i < received.length; i++) {
+ const prev = received[i - 1]?.date;
+ const cur = received[i].date;
+ received[i].delayMs =
+ i > 0 && isValidDate(prev) && isValidDate(cur) ? cur - prev : null;
+ }
+
+ // Combine every Authentication-Results / ARC-Authentication-Results value.
+ const authText = [
+ ...getHeaderValues(lines, "Authentication-Results"),
+ ...getHeaderValues(lines, "ARC-Authentication-Results"),
+ ].join("; ");
+ const grab = (key) => authText.match(new RegExp(`\\b${key}=(\\w+)`, "i"))?.[1] ?? null;
+ const authResults = {
+ SPF: grab("spf"),
+ DKIM: grab("dkim"),
+ DMARC: grab("dmarc"),
+ CompAuth: grab("compauth"),
+ ARC: grab("arc"),
+ };
+
+ return { hops: received, totalMs: total, auth: authResults };
+ }, [emailSource]);
+
+ const authEntries = Object.entries(auth).filter(([, v]) => v);
+ const hasHops = hops.length > 0;
+
+ // Nothing worth showing (e.g. a body-only message with no Received chain).
+ if (!hasHops && authEntries.length === 0) return null;
+
+ const maxDelay = Math.max(0, ...hops.map((h) => h.delayMs ?? 0));
+
+ return (
+
+ Delivery Information}
+ action={
+ totalMs != null ? (
+
+ ) : null
+ }
+ />
+
+ {authEntries.length > 0 && (
+
+ {authEntries.map(([label, result]) => (
+
+ ))}
+
+ )}
+
+ {hasHops && (
+
+
+
+
+ #
+ Delay
+ From
+ By
+ With
+ Time
+
+
+
+ {hops.map((hop, index) => (
+
+ {index + 1}
+
+
+
+ 0 && hop.delayMs
+ ? `${Math.max(4, (hop.delayMs / maxDelay) * 100)}%`
+ : "0%",
+ backgroundColor:
+ hop.delayMs > 10000 ? "warning.main" : "primary.main",
+ }}
+ />
+
+ {formatDelay(hop.delayMs)}
+
+
+
+
+ {hop.from ?? "—"}
+
+
+
+ {hop.by ?? "—"}
+
+
+ {hop.with ?? "—"}
+
+
+
+ {hop.date ? hop.date.toLocaleString() : "—"}
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+};
+
+export default CippMessageDeliveryInfo;
diff --git a/src/components/CippComponents/CippMessageViewer.jsx b/src/components/CippComponents/CippMessageViewer.jsx
index 557f63daa7af..15ed513cd143 100644
--- a/src/components/CippComponents/CippMessageViewer.jsx
+++ b/src/components/CippComponents/CippMessageViewer.jsx
@@ -16,6 +16,10 @@ import {
DialogContent,
IconButton,
Tooltip,
+ TextField,
+ ToggleButton,
+ ToggleButtonGroup,
+ Collapse,
} from "@mui/material";
import { Box, Grid, Stack, ThemeProvider } from "@mui/system";
import { createTheme } from "@mui/material/styles";
@@ -37,6 +41,8 @@ import {
AccountCircle,
Close,
ReceiptLong,
+ ExpandLess,
+ ExpandMore,
} from "@mui/icons-material";
import { CippTimeAgo } from "./CippTimeAgo";
@@ -53,6 +59,7 @@ import {
} from "@heroicons/react/24/outline";
import { useSettings } from "../../hooks/use-settings";
import CippForefrontHeaderDialog from "./CippForefrontHeaderDialog";
+import { CippMessageDeliveryInfo } from "./CippMessageDeliveryInfo";
export const CippMessageViewer = ({ emailSource }) => {
const [emlContent, setEmlContent] = useState(null);
@@ -308,6 +315,8 @@ export const CippMessageViewer = ({ emailSource }) => {
return (
<>
+
+
{emlError && (
@@ -549,6 +558,10 @@ export const CippMessageViewer = ({ emailSource }) => {
const CippMessageViewerPage = () => {
const [emlFile, setEmlFile] = useState(null);
+ const [inputMode, setInputMode] = useState("upload");
+ const [pasteValue, setPasteValue] = useState("");
+ const [pasteCollapsed, setPasteCollapsed] = useState(false);
+
const onDrop = useCallback((acceptedFiles) => {
acceptedFiles.forEach((file) => {
const reader = new FileReader();
@@ -561,14 +574,85 @@ const CippMessageViewerPage = () => {
});
}, []);
+ const handleModeChange = (event, newMode) => {
+ if (newMode !== null) {
+ setInputMode(newMode);
+ setEmlFile(null);
+ setPasteCollapsed(false);
+ }
+ };
+
+ const handleAnalyze = () => {
+ setEmlFile(pasteValue);
+ setPasteCollapsed(true);
+ };
+
return (
-
+
+
+ Upload EML
+ Paste headers / source
+
+
+ {inputMode === "paste" && (
+
+
+
+ setPasteCollapsed((prev) => !prev)}>
+
+ {pasteCollapsed ? : }
+
+
+
+
+ )}
+
+
+ {inputMode === "upload" ? (
+
+ ) : (
+
+ setPasteValue(e.target.value)}
+ placeholder="Paste raw email headers or the full message source here"
+ slotProps={{ input: { sx: { fontFamily: "monospace", fontSize: "0.8rem" } } }}
+ />
+
+ )}
+
{emlFile && }
);
diff --git a/src/components/CippComponents/CippNotificationForm.jsx b/src/components/CippComponents/CippNotificationForm.jsx
index f2f74b19d7f1..03ecce096ec9 100644
--- a/src/components/CippComponents/CippNotificationForm.jsx
+++ b/src/components/CippComponents/CippNotificationForm.jsx
@@ -42,7 +42,6 @@ export const CippNotificationForm = ({
{ label: "Adding a group", value: "AddGroup" },
{ label: "Adding a tenant", value: "NewTenant" },
{ label: "Executing the offboard wizard", value: "ExecOffboardUser" },
- { label: "Custom Test Alerts", value: "CustomTests" },
];
const severityTypes = [
diff --git a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx
index 5b6189ad2a7c..34fefd8ab509 100644
--- a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx
+++ b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx
@@ -1,49 +1,43 @@
import { CippPropertyListCard } from '../../components/CippCards/CippPropertyListCard'
import CippFormComponent from '../../components/CippComponents/CippFormComponent'
-import { Typography, Box } from '@mui/material'
+import { Box, Chip, Typography } from '@mui/material'
+import { Grid } from '@mui/system'
export const CippOffboardingDefaultSettings = (props) => {
const { formControl, defaultsSource = null, title = 'Offboarding Default Settings' } = props
- const getSourceIndicator = () => {
- // Only show the indicator if defaultsSource is explicitly provided (for wizard, not tenant config)
- if (!defaultsSource || defaultsSource === null) return null
+ const getSourceChip = () => {
+ // Only show the chip if defaultsSource is explicitly provided (for wizard/preferences, not tenant config)
+ if (!defaultsSource) return null
- let sourceText = ''
- let color = 'text.secondary'
-
- switch (defaultsSource) {
- case 'tenant':
- sourceText = 'Using Tenant Defaults'
- color = 'primary.main'
- break
- case 'user':
- sourceText = 'Using User Defaults'
- color = 'info.main'
- break
- case 'none':
- default:
- sourceText = 'Using Default Settings'
- color = 'text.secondary'
- break
+ const sourceConfig = {
+ tenant: { label: 'Using Tenant Defaults', color: 'primary' },
+ user: { label: 'Using User Defaults', color: 'info' },
+ allUsers: { label: 'Using All Users Defaults', color: 'default' },
+ none: { label: 'Using Default Settings', color: 'default' },
}
- return (
-
-
- {sourceText}
-
-
- )
+ const { label, color } = sourceConfig[defaultsSource] ?? sourceConfig.none
+
+ return
}
+ const sourceChip = getSourceChip()
+ const cardTitle = sourceChip ? (
+
+ {title}
+ {sourceChip}
+
+ ) : (
+ title
+ )
+
return (
<>
- {getSourceIndicator()}
{
),
},
]}
+ cardButton={
+
+
+ Send results to
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
/>
>
)
diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx
index ad695d39cace..6c0eab9bc7b3 100644
--- a/src/components/CippComponents/CippPolicyDeployDrawer.jsx
+++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx
@@ -17,6 +17,38 @@ const assignmentFilterTypeOptions = [
{ label: 'Exclude - Apply policy to devices NOT matching filter', value: 'exclude' },
]
+// Reserved replacement variables handled server-side by Get-CIPPTextReplacement.
+// These are populated automatically per tenant, so they must never be prompted for here.
+// Stored without the surrounding %% and lowercased for case-insensitive matching, since
+// templates may reference them in any casing (e.g. %TenantId%, %tenantid%).
+const reservedReplacementVariables = new Set(
+ [
+ 'serial',
+ 'systemroot',
+ 'systemdrive',
+ 'system32',
+ 'osdrive',
+ 'temp',
+ 'tenantid',
+ 'tenantfilter',
+ 'initialdomain',
+ 'tenantname',
+ 'partnertenantid',
+ 'samappid',
+ 'userprofile',
+ 'username',
+ 'userdomain',
+ 'windir',
+ 'programfiles',
+ 'programfiles(x86)',
+ 'programdata',
+ 'cippuserschema',
+ 'cippurl',
+ 'defaultdomain',
+ 'organizationid',
+ ].map((variable) => variable.toLowerCase()),
+)
+
export const CippPolicyDeployDrawer = ({
buttonText = 'Deploy Policy',
requiredPermissions = [],
@@ -259,7 +291,9 @@ export const CippPolicyDeployDrawer = ({
{(() => {
const rawJson = jsonWatch ? jsonWatch : ''
const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1])
- const uniquePlaceholders = Array.from(new Set(placeholderMatches))
+ const uniquePlaceholders = Array.from(new Set(placeholderMatches)).filter(
+ (placeholder) => !reservedReplacementVariables.has(placeholder.toLowerCase()),
+ )
if (uniquePlaceholders.length === 0 || selectedTenants.length === 0) {
return null
}
diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx
index 7c1630ce2eb4..ca9f5cf4bf40 100644
--- a/src/components/CippComponents/CippPolicyImportDrawer.jsx
+++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx
@@ -47,7 +47,7 @@ export const CippPolicyImportDrawer = ({
const tenantPolicies = ApiGetCall({
url:
mode === 'ConditionalAccess'
- ? `/api/ListCATemplates?TenantFilter=${tenantFilter?.value || ''}`
+ ? `/api/ListConditionalAccessPolicies?TenantFilter=${tenantFilter?.value || ''}`
: mode === 'Standards'
? `/api/listStandardTemplates?TenantFilter=${tenantFilter?.value || ''}`
: `/api/ListIntunePolicy?type=ESP&TenantFilter=${tenantFilter?.value || ''}`,
@@ -110,12 +110,14 @@ export const CippPolicyImportDrawer = ({
// For Conditional Access, convert RawJSON to object and send the contents
let policyData = policy
- // If the policy has RawJSON, parse it and use that as the data
- if (policy.RawJSON) {
+ // If the policy has rawjson, parse it and use that as the data.
+ // ListConditionalAccessPolicies returns the raw Graph policy as lowercase `rawjson`.
+ const rawJson = policy.rawjson ?? policy.RawJSON
+ if (rawJson) {
try {
- policyData = JSON.parse(policy.RawJSON)
+ policyData = JSON.parse(rawJson)
} catch (e) {
- console.error('Failed to parse RawJSON:', e)
+ console.error('Failed to parse rawjson:', e)
policyData = policy
}
}
@@ -187,8 +189,19 @@ export const CippPolicyImportDrawer = ({
},
})
} else {
- // For tenant policies, use the policy object directly
- setViewingPolicy(policy || {})
+ // For tenant policies, show the raw policy JSON when available
+ // (ConditionalAccess returns the Graph policy as lowercase `rawjson`).
+ const rawJson = policy?.rawjson ?? policy?.RawJSON
+ if (mode === 'ConditionalAccess' && rawJson) {
+ try {
+ setViewingPolicy(JSON.parse(rawJson))
+ } catch (e) {
+ console.error('Failed to parse rawjson for view:', e)
+ setViewingPolicy(policy || {})
+ }
+ } else {
+ setViewingPolicy(policy || {})
+ }
}
setViewDialogOpen(true)
} catch (error) {
diff --git a/src/components/CippComponents/CippSankey.jsx b/src/components/CippComponents/CippSankey.jsx
index eb583b801ac4..f22f091e80cc 100644
--- a/src/components/CippComponents/CippSankey.jsx
+++ b/src/components/CippComponents/CippSankey.jsx
@@ -39,6 +39,7 @@ export const CippSankey = ({ data, onNodeClick, onLinkClick }) => {
margin={{ top: 10, right: 10, bottom: 10, left: 10 }}
align="justify"
colors={(node) => node.nodeColor}
+ label={(node) => node.label ?? node.id}
nodeOpacity={1}
nodeHoverOthersOpacity={0.35}
nodeThickness={18}
diff --git a/src/components/CippComponents/CippSettingsSideBar.jsx b/src/components/CippComponents/CippSettingsSideBar.jsx
index 368ca3f19696..f3b07cf36905 100644
--- a/src/components/CippComponents/CippSettingsSideBar.jsx
+++ b/src/components/CippComponents/CippSettingsSideBar.jsx
@@ -104,6 +104,8 @@ export const CippSettingsSideBar = (props) => {
RemoveMFADevices: formValues.offboardingDefaults?.RemoveMFADevices,
RemoveTeamsPhoneDID: formValues.offboardingDefaults?.RemoveTeamsPhoneDID,
ClearImmutableId: formValues.offboardingDefaults?.ClearImmutableId,
+ removeCalendarPermissions: formValues.offboardingDefaults?.removeCalendarPermissions,
+ DisableOneDriveSharing: formValues.offboardingDefaults?.DisableOneDriveSharing,
},
};
diff --git a/src/components/CippComponents/CippSitRulePackDetails.jsx b/src/components/CippComponents/CippSitRulePackDetails.jsx
new file mode 100644
index 000000000000..ff7d65f456a0
--- /dev/null
+++ b/src/components/CippComponents/CippSitRulePackDetails.jsx
@@ -0,0 +1,60 @@
+import { Alert, CircularProgress, Stack, Typography } from '@mui/material'
+import { ApiGetCall } from '../../api/ApiCall'
+import { CippCodeBlock } from './CippCodeBlock'
+
+// More-info panel for a live custom Sensitive Information Type: looks up its rule pack by RulePackId and
+// shows what it actually detects (parsed configuration + the raw ClassificationRuleCollection XML).
+export const CippSitRulePackDetails = ({ row, tenant }) => {
+ const isCustom = Boolean(row?.Publisher) && !String(row.Publisher).startsWith('Microsoft')
+ // Only classic regex/keyword (Entity) SITs have an inspectable rule configuration.
+ const isEntity = row?.Type === 'Entity'
+ const shouldShow = isCustom && isEntity
+ const tenantFilter = tenant === 'AllTenants' && row?.Tenant ? row.Tenant : tenant
+
+ const rulePack = ApiGetCall({
+ url: '/api/ListSensitiveInfoTypeRulePackage',
+ queryKey: `SitRulePack-${tenantFilter}-${row?.RulePackId}`,
+ data: { tenantFilter, RulePackId: row?.RulePackId },
+ waiting: Boolean(shouldShow && tenantFilter && row?.RulePackId),
+ retry: 1,
+ refetchOnWindowFocus: false,
+ toast: false,
+ })
+
+ if (!shouldShow) {
+ return null
+ }
+
+ if (rulePack.isLoading || rulePack.isFetching) {
+ return (
+
+
+
+ Looking up rule pack {row?.RulePackId}...
+
+
+ )
+ }
+
+ if (rulePack.isError || !rulePack.data?.Xml) {
+ return (
+
+ Could not load the rule pack configuration for this Sensitive Information Type.
+
+ )
+ }
+
+ const data = rulePack.data
+ return (
+
+ Detection configuration
+
+ Rule pack XML ({data.RulePackId})
+
+
+ )
+}
diff --git a/src/components/CippComponents/CippSitTemplateDetails.jsx b/src/components/CippComponents/CippSitTemplateDetails.jsx
new file mode 100644
index 000000000000..001cae8d0059
--- /dev/null
+++ b/src/components/CippComponents/CippSitTemplateDetails.jsx
@@ -0,0 +1,140 @@
+import { Alert, Stack, Typography } from '@mui/material'
+import { CippCodeBlock } from './CippCodeBlock'
+
+// Decode the stored FileDataBase64 (UTF-16LE rule pack bytes) back into XML for exploring.
+const decodeFileData = (b64) => {
+ try {
+ const bin = atob(b64)
+ const bytes = new Uint8Array(bin.length)
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
+ let xml = new TextDecoder('utf-16le').decode(bytes)
+ if (!xml.includes(' { confidence, proximity, description, patterns:[{ level, matches:[regex:.. / keyword:.. ] }] }.
+const parseSitConfig = (xml) => {
+ try {
+ const doc = new DOMParser().parseFromString(xml, 'application/xml')
+ if (doc.getElementsByTagName('parsererror').length) return null
+ const all = Array.from(doc.getElementsByTagName('*'))
+ const byLocal = (name) => all.filter((n) => n.localName === name)
+
+ const regexMap = {}
+ byLocal('Regex').forEach((n) => {
+ if (n.getAttribute('id')) regexMap[n.getAttribute('id')] = (n.textContent || '').trim()
+ })
+ const keywordMap = {}
+ byLocal('Keyword').forEach((n) => {
+ if (!n.getAttribute('id')) return
+ const terms = Array.from(n.getElementsByTagName('*'))
+ .filter((t) => t.localName === 'Term')
+ .map((t) => (t.textContent || '').trim())
+ .sort()
+ keywordMap[n.getAttribute('id')] = terms.join('|')
+ })
+ const resMap = {}
+ byLocal('Resource').forEach((res) => {
+ const idRef = res.getAttribute('idRef')
+ if (!idRef) return
+ const kids = Array.from(res.children)
+ resMap[idRef] = {
+ name: kids.find((c) => c.localName === 'Name')?.textContent?.trim() || '',
+ description: kids.find((c) => c.localName === 'Description')?.textContent?.trim() || '',
+ }
+ })
+
+ const config = {}
+ all
+ .filter((n) => n.localName === 'Entity' || n.localName === 'Affinity')
+ .forEach((ent) => {
+ const eid = ent.getAttribute('id')
+ const name = resMap[eid]?.name || eid
+ const patterns = Array.from(ent.getElementsByTagName('*'))
+ .filter((p) => p.localName === 'Pattern' || p.localName === 'Evidence')
+ .map((p) => {
+ const matches = Array.from(p.getElementsByTagName('*'))
+ .filter((m) => m.getAttribute('idRef'))
+ .map((m) => {
+ const ref = m.getAttribute('idRef')
+ if (regexMap[ref] !== undefined) return `regex:${regexMap[ref]}`
+ if (keywordMap[ref] !== undefined) return `keyword:${keywordMap[ref]}`
+ return `fingerprint:${ref}`
+ })
+ .sort()
+ return { level: p.getAttribute('confidenceLevel') || '', matches }
+ })
+ config[name] = {
+ confidence:
+ ent.getAttribute('recommendedConfidence') || ent.getAttribute('thresholdConfidenceLevel') || '',
+ proximity: ent.getAttribute('patternsProximity') || ent.getAttribute('evidencesProximity') || '',
+ description: resMap[eid]?.description || '',
+ patterns,
+ }
+ })
+ return config
+ } catch {
+ return null
+ }
+}
+
+// More-info panel for a Sensitive Information Type template: explore the captured rule pack data.
+export const CippSitTemplateDetails = ({ row }) => {
+ const isAdvanced = Boolean(row?.FileDataBase64)
+ const xml = isAdvanced ? decodeFileData(row.FileDataBase64) : null
+ const config = xml ? parseSitConfig(xml) : null
+
+ return (
+
+
+ {isAdvanced
+ ? 'Advanced template — the captured rule pack is stored as base64. The decoded detection config and XML below are exactly what gets deployed.'
+ : 'Simple template — the backend synthesizes a rule pack from this pattern at deploy time.'}
+
+
+ {!isAdvanced && row?.Pattern && (
+
+ )}
+
+ {isAdvanced && config && Object.keys(config).length > 0 && (
+ <>
+ Detection configuration
+
+ >
+ )}
+
+ {isAdvanced && xml && (
+ <>
+ Rule pack XML (decoded from base64)
+
+ >
+ )}
+
+ {isAdvanced && !xml && (
+
+ Could not decode the stored rule pack data.
+
+ )}
+
+ )
+}
diff --git a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx
index 05ed8e18f836..490818307141 100644
--- a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx
+++ b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx
@@ -46,6 +46,9 @@ export const CippTenantGroupOffCanvas = ({ data }) => {
ne: "not equals",
in: "in",
notIn: "not in",
+ notin: "not in",
+ like: "contains",
+ notlike: "does not contain",
contains: "contains",
startsWith: "starts with",
endsWith: "ends with",
@@ -54,6 +57,54 @@ export const CippTenantGroupOffCanvas = ({ data }) => {
// Handle both single rule object and array of rules
const rules = Array.isArray(data.DynamicRules) ? data.DynamicRules : [data.DynamicRules];
+ // Resolve a value that may be a string, a {label, value} option, or a raw object
+ const resolveOptionLabel = (item) => {
+ if (item === null || item === undefined) return "";
+ if (typeof item === "object") return item.label ?? item.value ?? JSON.stringify(item);
+ return item;
+ };
+
+ const renderRuleValue = (rule) => {
+ // Custom Variable rules store a nested { variableName, value } object
+ if (rule.property === "customVariable" || rule.value?.variableName !== undefined) {
+ const variableName = resolveOptionLabel(rule.value?.variableName);
+ const expectedValue = resolveOptionLabel(rule.value?.value);
+ return (
+
+ );
+ }
+
+ if (Array.isArray(rule.value)) {
+ return (
+
+ {rule.value.map((item, valueIndex) => (
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ );
+ };
+
const renderRule = (rule, index) => (
{
Value(s):
- {Array.isArray(rule.value) ? (
-
- {rule.value.map((item, valueIndex) => (
-
- ))}
-
- ) : (
-
- )}
+ {renderRuleValue(rule)}
);
diff --git a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx
index e61aaf768be5..3133124dfe0b 100644
--- a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx
+++ b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx
@@ -40,9 +40,11 @@ const CippTenantGroupRuleBuilder = ({ formControl, name = "dynamicRules" }) => {
// Flatten all pages and extract Results
const allGroups = tenantGroupsQuery.data.pages.flatMap((page) => page?.Results || []);
return allGroups
- .filter((group) => group.GroupType === "static")
.map((group) => ({
- label: group.Name || group.displayName,
+ label:
+ group.GroupType === "dynamic"
+ ? `${group.Name || group.displayName} (dynamic)`
+ : group.Name || group.displayName,
value: group.Id || group.RowKey,
type: group.GroupType,
}))
diff --git a/src/components/CippComponents/CippTimeAgo.jsx b/src/components/CippComponents/CippTimeAgo.jsx
index a97d71e03acc..9573b4cfa936 100644
--- a/src/components/CippComponents/CippTimeAgo.jsx
+++ b/src/components/CippComponents/CippTimeAgo.jsx
@@ -1,11 +1,10 @@
import { Chip } from "@mui/material";
import ReactTimeAgo from "react-time-ago";
+import { parseCippDate } from "../../utils/parse-cipp-date";
export const CippTimeAgo = ({ data, type = "text", timeStyle = "round-minute" }) => {
const isText = type === "text";
- const numberRegex = /^\d+$/;
- const date =
- typeof data === "number" || numberRegex.test(data) ? new Date(data * 1000) : new Date(data);
+ const date = parseCippDate(data);
if (date.getTime() === 0) {
return "Never";
diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx
index 2ac784716799..deaa03a68fac 100644
--- a/src/components/CippComponents/CippUserActions.jsx
+++ b/src/components/CippComponents/CippUserActions.jsx
@@ -451,6 +451,7 @@ export const useCippUserActions = () => {
confirmText:
'Are you sure you want to create a Temporary Access Pass for [userPrincipalName]?',
multiPost: false,
+ allowResubmit: true,
condition: () => canWriteUser,
},
{
diff --git a/src/components/CippComponents/EnterpriseAppActions.jsx b/src/components/CippComponents/EnterpriseAppActions.jsx
index c55f87d8d240..1d97f6910211 100644
--- a/src/components/CippComponents/EnterpriseAppActions.jsx
+++ b/src/components/CippComponents/EnterpriseAppActions.jsx
@@ -44,7 +44,7 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [
},
],
confirmText:
- "Create a deployment template from '[displayName]'? This will copy all permissions and create a reusable template.",
+ "'[displayName]' is a multi-tenant app, so a multi-tenant Enterprise App template will be created. This copies all permissions into a reusable template.",
condition: (row) => canWriteApplication && row?.signInAudience === 'AzureADMultipleOrgs',
},
{
diff --git a/src/components/CippComponents/LicenseCard.jsx b/src/components/CippComponents/LicenseCard.jsx
index dce02b1e12f6..5e59011903b3 100644
--- a/src/components/CippComponents/LicenseCard.jsx
+++ b/src/components/CippComponents/LicenseCard.jsx
@@ -1,8 +1,10 @@
import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from "@mui/material";
import { CardMembership as CardMembershipIcon } from "@mui/icons-material";
import { CippSankey } from "./CippSankey";
+import { useRouter } from "next/router";
export const LicenseCard = ({ data, isLoading }) => {
+ const router = useRouter();
const processData = () => {
if (!data || !Array.isArray(data) || data.length === 0) {
return null;
@@ -19,6 +21,7 @@ export const LicenseCard = ({ data, isLoading }) => {
const nodes = [];
const links = [];
+ const licenseLookup = {};
topLicenses.forEach((license, index) => {
if (license) {
@@ -30,22 +33,33 @@ export const LicenseCard = ({ data, isLoading }) => {
const assigned = parseInt(license?.CountUsed || 0) || 0;
const available = parseInt(license?.CountAvailable || 0) || 0;
+ // Use the index to keep node ids unique even when two licenses truncate
+ // to the same shortName; the visible label stays the truncated name.
+ const nodeId = `${index}-${shortName}`;
+ const assignedId = `${nodeId} - Assigned`;
+ const availableId = `${nodeId} - Available`;
+
nodes.push({
- id: shortName,
+ id: nodeId,
+ label: shortName,
nodeColor: `hsl(${210 + index * 30}, 70%, 50%)`,
});
- const assignedId = `${shortName} - Assigned`;
- const availableId = `${shortName} - Available`;
+ // Map every node id back to the full license name so a click can filter
+ // the report on the real License value.
+ licenseLookup[nodeId] = licenseName;
+ licenseLookup[assignedId] = licenseName;
+ licenseLookup[availableId] = licenseName;
if (assigned > 0) {
nodes.push({
id: assignedId,
+ label: `${shortName} - Assigned`,
nodeColor: "hsl(99, 70%, 50%)",
});
links.push({
- source: shortName,
+ source: nodeId,
target: assignedId,
value: assigned,
});
@@ -54,11 +68,12 @@ export const LicenseCard = ({ data, isLoading }) => {
if (available > 0) {
nodes.push({
id: availableId,
+ label: `${shortName} - Available`,
nodeColor: "hsl(28, 100%, 53%)",
});
links.push({
- source: shortName,
+ source: nodeId,
target: availableId,
value: available,
});
@@ -70,11 +85,30 @@ export const LicenseCard = ({ data, isLoading }) => {
return null;
}
- return { nodes, links };
+ return { nodes, links, licenseLookup };
};
const processedData = processData();
+ const navigateToLicense = (nodeId) => {
+ const fullName = processedData?.licenseLookup?.[nodeId];
+ if (!fullName) {
+ return;
+ }
+ router.push({
+ pathname: "/tenant/reports/list-licenses",
+ query: { filters: JSON.stringify([{ id: "License", value: fullName }]) },
+ });
+ };
+
+ const handleNodeClick = (node) => {
+ navigateToLicense(node?.id);
+ };
+
+ const handleLinkClick = (link) => {
+ navigateToLicense(link?.source?.id ?? link?.source);
+ };
+
const calculateStats = () => {
if (!data || !Array.isArray(data)) {
return { total: 0, assigned: 0, available: 0 };
@@ -93,7 +127,17 @@ export const LicenseCard = ({ data, isLoading }) => {
+ router.push("/tenant/reports/list-licenses")}
+ sx={{
+ display: "flex",
+ alignItems: "center",
+ gap: 1,
+ cursor: "pointer",
+ width: "fit-content",
+ "&:hover": { textDecoration: "underline" },
+ }}
+ >
License Overview
@@ -105,7 +149,11 @@ export const LicenseCard = ({ data, isLoading }) => {
{isLoading ? (
) : processedData ? (
-
+
) : (
{
+ router.push("/identity/reports/mfa-report")}
+ sx={{
+ display: "flex",
+ alignItems: "center",
+ gap: 1,
+ cursor: "pointer",
+ width: "fit-content",
+ "&:hover": { textDecoration: "underline" },
+ }}
+ >
User authentication
diff --git a/src/components/CippComponents/SecureScoreCard.jsx b/src/components/CippComponents/SecureScoreCard.jsx
index 51940abfc2d9..a1732a6d41d5 100644
--- a/src/components/CippComponents/SecureScoreCard.jsx
+++ b/src/components/CippComponents/SecureScoreCard.jsx
@@ -1,5 +1,6 @@
import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from '@mui/material'
import { Security as SecurityIcon } from '@mui/icons-material'
+import { useRouter } from 'next/router'
import {
LineChart,
Line,
@@ -12,11 +13,22 @@ import {
} from 'recharts'
export const SecureScoreCard = ({ data, isLoading }) => {
+ const router = useRouter()
return (
+ router.push('/tenant/administration/securescore')}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 1,
+ cursor: 'pointer',
+ width: 'fit-content',
+ '&:hover': { textDecoration: 'underline' },
+ }}
+ >
Secure Score
diff --git a/src/components/CippComponents/TenantMetricsGrid.jsx b/src/components/CippComponents/TenantMetricsGrid.jsx
index 323bd44a7f9f..35eda0143286 100644
--- a/src/components/CippComponents/TenantMetricsGrid.jsx
+++ b/src/components/CippComponents/TenantMetricsGrid.jsx
@@ -84,12 +84,13 @@ export const TenantMetricsGrid = ({ data, isLoading }) => {
sx={{
display: "flex",
alignItems: "center",
- gap: 1.5,
- p: 2,
+ gap: { xs: 1, sm: 1.5 },
+ p: { xs: 1, sm: 1.5, md: 2 },
border: 1,
borderColor: "divider",
borderRadius: 1,
cursor: "pointer",
+ minWidth: 0,
transition: "all 0.2s ease-in-out",
"&:hover": {
borderColor: `${metric.color}.main`,
@@ -103,18 +104,24 @@ export const TenantMetricsGrid = ({ data, isLoading }) => {
sx={{
bgcolor: `${metric.color}.main`,
color: `${metric.color}.contrastText`,
- width: 34,
- height: 34,
+ width: { xs: 28, sm: 32, md: 34 },
+ height: { xs: 28, sm: 32, md: 34 },
+ flexShrink: 0,
}}
>
-
+
-
-
+
+
{metric.label}
-
- {isLoading ? : formatNumber(metric.value)}
+
+ {isLoading ? : formatNumber(metric.value)}
diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx
index 9a3559190372..e4cb3af89a8a 100644
--- a/src/components/CippFormPages/CippAddEditUser.jsx
+++ b/src/components/CippFormPages/CippAddEditUser.jsx
@@ -50,7 +50,7 @@ const CippAddEditUser = (props) => {
// Get all groups for the tenant
const tenantGroups = ApiGetCall({
url: `/api/ListGroups?tenantFilter=${tenantDomain}`,
- queryKey: `ListGroups-${tenantDomain}`,
+ queryKey: `TenantGroupsList-${tenantDomain}`,
refetchOnMount: false,
refetchOnReconnect: false,
})
@@ -318,7 +318,12 @@ const CippAddEditUser = (props) => {
setFieldIfEmpty('companyName', template.companyName)
setFieldIfEmpty('department', template.department)
setFieldIfEmpty('mobilePhone', template.mobilePhone)
- setFieldIfEmpty('businessPhones[0]', template.businessPhones)
+ const templateBusinessPhone = Array.isArray(template.businessPhones)
+ ? template.businessPhones[0]
+ : template.businessPhones
+ if (templateBusinessPhone) {
+ formControl.setValue('businessPhones', [templateBusinessPhone])
+ }
// Handle licenses - need to match the format expected by CippFormLicenseSelector
if (template.licenses && Array.isArray(template.licenses)) {
@@ -825,6 +830,7 @@ const CippAddEditUser = (props) => {
value: group.id,
addedFields: {
groupType: group.groupType,
+ calculatedGroupType: group.calculatedGroupType,
},
})) || []
}
@@ -914,65 +920,59 @@ const CippAddEditUser = (props) => {
})}
>
)}
- {/* Schedule User Creation */}
- {formType === 'add' && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
+ {/* Schedule User Creation / Edit */}
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
)
}
diff --git a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx
index 53a88787de19..76162fef0daa 100644
--- a/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx
+++ b/src/components/CippIntegrations/CippIntegrationFieldMapping.jsx
@@ -42,7 +42,7 @@ const CippIntegrationFieldMapping = () => {
var missingMappings = [];
fieldMapping?.data?.Mappings?.forEach((mapping) => {
const exists = fieldMapping?.data?.IntegrationFields?.some(
- (integrationField) => String(integrationField.value) === mapping.IntegrationId
+ (integrationField) => String(integrationField?.value) === mapping.IntegrationId
);
if (exists) {
newMappings[mapping.RowKey] = {
diff --git a/src/components/CippIntegrations/CippIntegrationSettings.jsx b/src/components/CippIntegrations/CippIntegrationSettings.jsx
index d0156df3897a..e5a8cb67cfe4 100644
--- a/src/components/CippIntegrations/CippIntegrationSettings.jsx
+++ b/src/components/CippIntegrations/CippIntegrationSettings.jsx
@@ -62,7 +62,7 @@ const CippIntegrationSettings = ({ children }) => {
{setting?.condition ? (
s.name === `${extension.id}.Enabled`) && !enabled}>
-
+
{
) : (
-
+
{
const { executeCheck, offcanvasVisible, setOffcanvasVisible, importReport, setCardIcon } = props;
const [results, setResults] = useState({});
+ const repairRoleMappings = ApiPostCall({
+ urlFromData: true,
+ relatedQueryKeys: ["ExecAccessChecks-GDAP"],
+ });
+
+ const handleRepairRoleMappings = () => {
+ repairRoleMappings.mutate({
+ url: "/api/ExecGDAPRepairRoleMappings",
+ data: {},
+ queryKey: "RepairGDAPRoleMappings",
+ });
+ };
+
+ const hasRoleMappingIssues = results?.Results?.RoleMappingResults?.some(
+ (item) => item?.Status === "Stale" || item?.Status === "Missing",
+ );
+
useEffect(() => {
if (importReport) {
setResults(importReport);
@@ -19,7 +38,11 @@ export const CippGDAPResults = (props) => {
}, [executeCheck, importReport]);
useEffect(() => {
- if (results?.Results?.GDAPIssues?.length > 0 || results?.Results?.MissingGroups?.length > 0) {
+ if (
+ results?.Results?.GDAPIssues?.length > 0 ||
+ results?.Results?.MissingGroups?.length > 0 ||
+ hasRoleMappingIssues
+ ) {
setCardIcon();
} else {
setCardIcon();
@@ -77,6 +100,15 @@ export const CippGDAPResults = (props) => {
successMessage: "No Global Admin relationships found",
failureMessage: "Global Admin relationships found",
},
+ {
+ resultProperty: "RoleMappingResults",
+ matchProperty: "Status",
+ match: "^(Stale|Missing)$",
+ count: 0,
+ successMessage: "All GDAP role mappings reference existing security groups",
+ failureMessage:
+ "One or more GDAP role mappings reference stale or missing security groups. Click Details to repair.",
+ },
];
const propertyItems = [
@@ -154,13 +186,16 @@ export const CippGDAPResults = (props) => {
}}
extendedInfo={[]}
>
- {results?.Results?.GDAPIssues?.length > 0 && (
+ {results?.Results?.GDAPIssues?.filter((issue) => issue.Category !== "RoleMapping")
+ .length > 0 && (
<>
issue.Category !== "RoleMapping",
+ )}
simpleColumns={["Tenant", "Type", "Issue", "Link"]}
/>
>
@@ -178,6 +213,37 @@ export const CippGDAPResults = (props) => {
>
)}
+ {results?.Results?.RoleMappingResults?.length > 0 && (
+ <>
+
+
+
+
+ }
+ >
+ Repair Role Mappings
+