diff --git a/package.json b/package.json
index 07dae7f34412..9ec2d301b018 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cipp",
- "version": "10.3.1",
+ "version": "10.4.0",
"author": "CIPP Contributors",
"homepage": "https://cipp.app/",
"bugs": {
diff --git a/public/version.json b/public/version.json
index 5646fed27b32..ce23da362f89 100644
--- a/public/version.json
+++ b/public/version.json
@@ -1,3 +1,3 @@
{
- "version": "10.3.1"
+ "version": "10.4.0"
}
diff --git a/src/components/CippTable/util-columnsFromAPI.js b/src/components/CippTable/util-columnsFromAPI.js
index fa6259e7982b..65fdbb411f19 100644
--- a/src/components/CippTable/util-columnsFromAPI.js
+++ b/src/components/CippTable/util-columnsFromAPI.js
@@ -18,11 +18,31 @@ const MAX_COL_SIZE = 500
// resize handle, filter icon). These sit alongside the header text and consume space.
const HEADER_CHROME_PX = 75
+// Extra pixels per chip for icon + internal padding + margin.
+const CHIP_CHROME_PX = 45
+
+// DateTime columns render as relative time (e.g. "about 2 months ago"). Use a fixed
+// character length instead of measuring the raw ISO date string.
+const RELATIVE_TIME_CHARS = 20
+
+// Known datetime accessor names and pattern — must stay in sync with get-cipp-formatting.js
+const TIME_AGO_NAMES = new Set([
+ 'ExecutedTime', 'ScheduledTime', 'Timestamp', 'timestamp', 'DateTime', 'LastRun',
+ 'LastRefresh', 'createdDateTime', 'activatedDateTime', 'lastModifiedDateTime',
+ 'endDateTime', 'ReceivedTime', 'Expires', 'updatedAt', 'createdAt', 'Received',
+ 'Date', 'WhenCreated', 'WhenChanged', 'CreationTime', 'renewalDate',
+ 'commitmentTerm.renewalConfiguration.renewalDate', 'purchaseDate', 'NextOccurrence',
+ 'LastOccurrence', 'NotBefore', 'NotAfter', 'latestDataCollection',
+ 'requestDate', 'reviewedDate', 'GeneratedAt',
+])
+const MATCH_DATE_TIME = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/
+const isDateTimeColumn = (key) => TIME_AGO_NAMES.has(key) || MATCH_DATE_TIME.test(key)
+
// Measure the pixel width a column needs based on its header and sampled cell values.
// rawValues are the original data values (before formatting) — if they contain arrays or
// complex objects the column renders as a button/chip list, so we cap to header width.
// Returns { size, minSize } where minSize is always header-width + chrome safe space.
-const measureColumnSize = (header, valuesForColumn, rawValues) => {
+const measureColumnSize = (header, valuesForColumn, rawValues, accessorKey) => {
const headerLen = header ? header.length : 6
const headerPx = Math.round(headerLen * CHAR_WIDTH + CELL_PADDING + HEADER_CHROME_PX)
const minSize = Math.max(MIN_COL_SIZE, headerPx)
@@ -40,34 +60,64 @@ const measureColumnSize = (header, valuesForColumn, rawValues) => {
// "X items" button (CippDataTableButton), so size to the button width.
const allObjectLike = rawValues.every((v) => {
if (v === null || v === undefined) return true // nulls are fine, they show "No items"
- if (Array.isArray(v)) return v.some((el) => typeof el === 'object' && el !== null)
+ if (Array.isArray(v)) return v.length === 0 || v.some((el) => typeof el === 'object' && el !== null)
return typeof v === 'object'
})
if (allObjectLike) {
- // "X items" button is roughly 80-100px wide — just use header width
- return { size: minSize, minSize }
+ // The formatted text tells us how this column actually renders:
+ // - JSON strings (starts with [ or {) → CippDataTableButton ("X items"), compact
+ // - Comma-separated text → chips/inline content, needs real measurement
+ const looksLikeButton = valuesForColumn.every((t) => {
+ if (t === null || t === undefined || t === '' || t === 'No data') return true
+ if (Array.isArray(t)) return true // handler returned a raw array (e.g. [])
+ const s = typeof t === 'string' ? t.trim() : ''
+ return s.startsWith('[') || s.startsWith('{') || s === 'Password hidden'
+ })
+ if (looksLikeButton) {
+ return { size: minSize, minSize }
+ }
+ // Object arrays that render as chips — measure the longest item from the
+ // comma-separated text representation.
+ let longestObjItem = headerLen
+ for (const t of valuesForColumn) {
+ if (typeof t !== 'string') continue
+ const parts = t.split(',')
+ for (const p of parts) {
+ const len = p.trim().length
+ if (len > longestObjItem) longestObjItem = len
+ }
+ }
+ const objChipPx = Math.round(longestObjItem * CHAR_WIDTH + CELL_PADDING + CHIP_CHROME_PX + HEADER_CHROME_PX)
+ const objSize = Math.max(minSize, Math.min(MAX_COL_SIZE, objChipPx))
+ return { size: objSize, minSize }
}
- // String/primitive arrays → rendered as chip list. Measure longest chip,
- // but cap per-chip text since chips truncate long values (e.g. email addresses).
- const MAX_CHIP_TEXT = 15
+ // String/primitive arrays → rendered as chip list. Measure the longest
+ // single item across all rows, then size like a regular text column.
let longestItem = headerLen
for (let i = 0; i < rawValues.length; i++) {
const v = rawValues[i]
if (Array.isArray(v)) {
for (const el of v) {
const s = typeof el === 'string' ? el : el != null ? String(el) : ''
- const len = Math.min(s.length, MAX_CHIP_TEXT)
- if (len > longestItem) longestItem = len
+ if (s.length > longestItem) longestItem = s.length
}
}
}
- const chipPx = Math.round(longestItem * CHAR_WIDTH + CELL_PADDING)
+ const chipPx = Math.round(longestItem * CHAR_WIDTH + CELL_PADDING + CHIP_CHROME_PX + HEADER_CHROME_PX)
const size = Math.max(minSize, Math.min(MAX_COL_SIZE, chipPx))
return { size, minSize }
}
}
+ // DateTime columns render as relative time — use a fixed width instead of the raw string.
+ if (accessorKey && isDateTimeColumn(accessorKey)) {
+ const dtLen = Math.max(headerLen, RELATIVE_TIME_CHARS)
+ const dtPx = Math.round(dtLen * CHAR_WIDTH + CELL_PADDING)
+ const size = Math.max(minSize, Math.min(MAX_COL_SIZE, dtPx))
+ return { size, minSize }
+ }
+
const sample =
valuesForColumn.length > MAX_SIZE_SAMPLE
? valuesForColumn.slice(0, MAX_SIZE_SAMPLE)
@@ -214,7 +264,7 @@ export const utilColumnsFromAPI = (dataArray) => {
// Measure content width from formatted text values for this column.
const textValues = valuesForColumn.map((v) => getCippFormatting(v, accessorKey, 'text'))
const header = getCippTranslation(accessorKey)
- const measuredSize = measureColumnSize(header, textValues, valuesForColumn)
+ const measuredSize = measureColumnSize(header, textValues, valuesForColumn, accessorKey)
// Allow per-column size overrides for columns whose rendered output
// doesn't match text width (icons, progress bars, etc.).
diff --git a/src/pages/endpoint/autopilot/list-devices/index.js b/src/pages/endpoint/autopilot/list-devices/index.js
index 3c25271def7c..c6694e3d1920 100644
--- a/src/pages/endpoint/autopilot/list-devices/index.js
+++ b/src/pages/endpoint/autopilot/list-devices/index.js
@@ -1,145 +1,151 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog.jsx";
-import { Button } from "@mui/material";
-import { PersonAdd, Delete, Sync, Add, Edit, Sell } from "@mui/icons-material";
-import { useDialog } from "../../../../hooks/use-dialog";
-import Link from "next/link";
+import { Layout as DashboardLayout } from '../../../../layouts/index.js'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog.jsx'
+import { Button } from '@mui/material'
+import { PersonAdd, Delete, Sync, Add, Edit, Sell } from '@mui/icons-material'
+import { useDialog } from '../../../../hooks/use-dialog'
+import Link from 'next/link'
const Page = () => {
- const pageTitle = "Autopilot Devices";
- const createDialog = useDialog();
+ const pageTitle = 'Autopilot Devices'
+ const createDialog = useDialog()
const actions = [
{
- label: "Assign device",
+ label: 'Assign device',
icon: ,
- type: "POST",
- url: "/api/ExecAssignAPDevice",
+ type: 'POST',
+ url: '/api/ExecAssignAPDevice',
data: {
- device: "id",
- serialNumber: "serialNumber",
+ device: 'id',
+ serialNumber: 'serialNumber',
},
- confirmText: "Select the user to assign the device to",
+ confirmText: 'Select the user to assign the device to',
fields: [
{
- type: "autoComplete",
- name: "user",
- label: "Select User",
+ type: 'autoComplete',
+ name: 'user',
+ label: 'Select User',
multiple: false,
creatable: false,
api: {
- url: "/api/listUsers",
+ url: '/api/listUsers',
labelField: (user) => `${user.displayName} (${user.userPrincipalName})`,
- valueField: "userPrincipalName",
+ valueField: 'userPrincipalName',
addedField: {
- userPrincipalName: "userPrincipalName",
- addressableUserName: "displayName",
+ userPrincipalName: 'userPrincipalName',
+ addressableUserName: 'displayName',
},
},
},
],
- color: "info",
+ color: 'info',
},
{
- label: "Rename Device",
+ label: 'Rename Device',
icon: ,
- type: "POST",
- url: "/api/ExecRenameAPDevice",
+ type: 'POST',
+ url: '/api/ExecRenameAPDevice',
data: {
- deviceId: "id",
- serialNumber: "serialNumber",
+ deviceId: 'id',
+ serialNumber: 'serialNumber',
},
- confirmText: "Enter the new display name for the device.",
+ confirmText: 'Enter the new display name for the device.',
fields: [
{
- type: "textField",
- name: "displayName",
- label: "New Display Name",
+ type: 'textField',
+ name: 'displayName',
+ label: 'New Display Name',
required: true,
validate: (value) => {
if (!value) {
- return "Display name is required.";
+ return 'Display name is required.'
}
if (value.length > 15) {
- return "Display name must be 15 characters or less.";
+ return 'Display name must be 15 characters or less.'
}
if (/\s/.test(value)) {
- return "Display name cannot contain spaces.";
+ return 'Display name cannot contain spaces.'
}
if (!/^[a-zA-Z0-9-]+$/.test(value)) {
- return "Display name can only contain letters, numbers, and hyphens.";
+ return 'Display name can only contain letters, numbers, and hyphens.'
}
if (/^[0-9]+$/.test(value)) {
- return "Display name cannot contain only numbers.";
+ return 'Display name cannot contain only numbers.'
}
- return true; // Indicates validation passed
+ return true // Indicates validation passed
},
},
],
- color: "secondary",
+ color: 'secondary',
},
{
- label: "Edit Group Tag",
+ label: 'Edit Group Tag',
icon: ,
- type: "POST",
- url: "/api/ExecSetAPDeviceGroupTag",
+ type: 'POST',
+ url: '/api/ExecSetAPDeviceGroupTag',
data: {
- deviceId: "id",
- serialNumber: "serialNumber",
+ deviceId: 'id',
+ serialNumber: 'serialNumber',
},
- confirmText: "Enter the new group tag for the device.",
+ confirmText: 'Enter the new group tag for the device.',
fields: [
{
- type: "textField",
- name: "groupTag",
- label: "Group Tag",
+ type: 'textField',
+ name: 'groupTag',
+ label: 'Group Tag',
validate: (value) => {
if (value && value.length > 128) {
- return "Group tag cannot exceed 128 characters.";
+ return 'Group tag cannot exceed 128 characters.'
}
- return true; // Validation passed
+ return true // Validation passed
},
},
],
- color: "secondary",
+ color: 'secondary',
},
{
- label: "Delete Device",
+ label: 'Delete Device',
icon: ,
- type: "POST",
- url: "/api/RemoveAPDevice",
- data: { ID: "id" },
- confirmText: "Are you sure you want to delete this device?",
- color: "danger",
+ type: 'POST',
+ url: '/api/RemoveAPDevice',
+ data: { ID: 'id' },
+ confirmText: 'Are you sure you want to delete this device?',
+ color: 'danger',
},
- ];
+ ]
const offCanvas = {
extendedInfoFields: [
- "userPrincipalName",
- "productKey",
- "serialNumber",
- "model",
- "manufacturer",
+ 'userPrincipalName',
+ 'productKey',
+ 'serialNumber',
+ 'model',
+ 'manufacturer',
],
actions: actions,
- };
+ }
const simpleColumns = [
- "displayName",
- "serialNumber",
- "model",
- "manufacturer",
- "groupTag",
- "enrollmentState",
- ];
+ 'Tenant',
+ 'displayName',
+ 'serialNumber',
+ 'model',
+ 'manufacturer',
+ 'groupTag',
+ 'enrollmentState',
+ ]
return (
<>
{
title="Sync Autopilot Devices"
createDialog={createDialog}
api={{
- type: "POST",
- url: "/api/ExecSyncAPDevices",
+ type: 'POST',
+ url: '/api/ExecSyncAPDevices',
data: {},
confirmText:
- "Are you sure you want to sync Autopilot devices? This can only be done every 10 minutes.",
+ 'Are you sure you want to sync Autopilot devices? This can only be done every 10 minutes.',
}}
/>
>
- );
-};
+ )
+}
-Page.getLayout = (page) => {page};
-export default Page;
+Page.getLayout = (page) => {page}
+export default Page
diff --git a/src/pages/endpoint/autopilot/list-status-pages/index.js b/src/pages/endpoint/autopilot/list-status-pages/index.js
index 4ec8a3b93313..affa4e128579 100644
--- a/src/pages/endpoint/autopilot/list-status-pages/index.js
+++ b/src/pages/endpoint/autopilot/list-status-pages/index.js
@@ -1,26 +1,34 @@
-import { Layout as DashboardLayout } from "../../../../layouts/index.js";
-import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
-import { CippAutopilotStatusPageDrawer } from "../../../../components/CippComponents/CippAutopilotStatusPageDrawer";
+import { Layout as DashboardLayout } from '../../../../layouts/index.js'
+import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx'
+import { CippAutopilotStatusPageDrawer } from '../../../../components/CippComponents/CippAutopilotStatusPageDrawer'
const Page = () => {
- const pageTitle = "Autopilot Status Pages";
+ const pageTitle = 'Autopilot Status Pages'
const simpleColumns = [
- "displayName",
- "Description",
- "installProgressTimeoutInMinutes",
- "showInstallationProgress",
- "blockDeviceSetupRetryByUser",
- "allowDeviceResetOnInstallFailure",
- "allowDeviceUseOnInstallFailure",
- ];
+ 'Tenant',
+ 'displayName',
+ 'Description',
+ 'installProgressTimeoutInMinutes',
+ 'showInstallationProgress',
+ 'blockDeviceSetupRetryByUser',
+ 'allowDeviceResetOnInstallFailure',
+ 'allowDeviceUseOnInstallFailure',
+ ]
// No actions specified in the original file, so none are included here.
return (
@@ -28,8 +36,8 @@ const Page = () => {
>
}
/>
- );
-};
+ )
+}
-Page.getLayout = (page) => {page};
-export default Page;
+Page.getLayout = (page) => {page}
+export default Page
diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx
index 4f636c58045c..dff059067894 100644
--- a/src/pages/tenant/administration/alert-configuration/alert.jsx
+++ b/src/pages/tenant/administration/alert-configuration/alert.jsx
@@ -145,7 +145,25 @@ const AlertWizard = () => {
alert.RawAlert.PostExecution.split(',').includes(opt.value)
)
let tenantFilterForForm
- if (alert.RawAlert.TenantGroup) {
+ if (alert.RawAlert.Tenants) {
+ // Multi tenant alert - parse stored JSON
+ try {
+ const parsedTenants =
+ typeof alert.RawAlert.Tenants === 'string'
+ ? JSON.parse(alert.RawAlert.Tenants)
+ : alert.RawAlert.Tenants
+ tenantFilterForForm = Array.isArray(parsedTenants) ? parsedTenants : [parsedTenants]
+ } catch (error) {
+ console.error('Error parsing Tenants:', error)
+ tenantFilterForForm = [
+ {
+ value: alert.RawAlert.Tenant,
+ label: alert.RawAlert.Tenant,
+ type: 'Tenant',
+ },
+ ]
+ }
+ } else if (alert.RawAlert.TenantGroup) {
try {
const tenantGroupObject = JSON.parse(alert.RawAlert.TenantGroup)
tenantFilterForForm = {
@@ -156,18 +174,23 @@ const AlertWizard = () => {
}
} catch (error) {
console.error('Error parsing tenant group:', error)
- tenantFilterForForm = {
+ tenantFilterForForm = [
+ {
+ value: alert.RawAlert.Tenant,
+ label: alert.RawAlert.Tenant,
+ type: 'Tenant',
+ },
+ ]
+ }
+ } else {
+ // Single tenant
+ tenantFilterForForm = [
+ {
value: alert.RawAlert.Tenant,
label: alert.RawAlert.Tenant,
type: 'Tenant',
- }
- }
- } else {
- tenantFilterForForm = {
- value: alert.RawAlert.Tenant,
- label: alert.RawAlert.Tenant,
- type: 'Tenant',
- }
+ },
+ ]
}
let startDateTimeForForm = null
if (alert.RawAlert.DesiredStartTime && alert.RawAlert.DesiredStartTime !== '0') {
@@ -472,13 +495,16 @@ const AlertWizard = () => {
return {}
}
+ const tenants = Array.isArray(values.tenantFilter) ? values.tenantFilter : [values.tenantFilter]
+ const tenantLabel = tenants.map((t) => t.label || t.value).join(', ')
+
const postObject = {
RowKey: router.query.clone ? undefined : router.query.id ? router.query.id : undefined,
tenantFilter: values.tenantFilter,
excludedTenants: values.excludedTenants,
Name: values.CustomSubject
- ? `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.CustomSubject}`
- : `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.command.label}`,
+ ? `${tenantLabel}: ${values.CustomSubject}`
+ : `${tenantLabel}: ${values.command.label}`,
Command: { value: `Get-CIPPAlert${values.command.value.name}` },
Parameters: getInputParams(),
ScheduledTime: Math.floor(new Date().getTime() / 1000) + 60,
@@ -489,7 +515,7 @@ const AlertWizard = () => {
CustomSubject: values.CustomSubject,
}
apiRequest.mutate(
- { url: '/api/AddScheduledItem?hidden=true', data: postObject },
+ { url: '/api/AddScriptedAlert', data: postObject },
{
onSuccess: () => {
// Prevent form reload after successful save
@@ -884,19 +910,22 @@ const AlertWizard = () => {
+ value?.length > 0 ||
+ 'At least one tenant or *All Tenants must be selected',
}}
/>
diff --git a/src/pages/tenant/reports/application-consent/index.js b/src/pages/tenant/reports/application-consent/index.js
index 3270740aa766..743b4226b763 100644
--- a/src/pages/tenant/reports/application-consent/index.js
+++ b/src/pages/tenant/reports/application-consent/index.js
@@ -1,15 +1,111 @@
import { Layout as DashboardLayout } from "../../../../layouts/index.js";
import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx";
+import { Sync, CloudDone, Bolt } from "@mui/icons-material";
+import { Button, SvgIcon, Tooltip, Chip } from "@mui/material";
+import { useSettings } from "../../../../hooks/use-settings";
+import { Stack } from "@mui/system";
+import { useDialog } from "../../../../hooks/use-dialog";
+import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog";
+import { useState, useEffect } from "react";
+import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker";
-const simpleColumns = ["Tenant", "Name", "ApplicationID", "ObjectID", "Scope", "StartTime"];
-
-const apiUrl = "/api/ListOAuthApps";
const pageTitle = "Consented Applications";
const Page = () => {
- return ;
+ const currentTenant = useSettings().currentTenant;
+ const syncDialog = useDialog();
+ const [syncQueueId, setSyncQueueId] = useState(null);
+
+ const isAllTenants = currentTenant === "AllTenants";
+ const [useReportDB, setUseReportDB] = useState(true);
+
+ useEffect(() => {
+ setUseReportDB(true);
+ }, [currentTenant]);
+
+ const simpleColumns = isAllTenants
+ ? ["Tenant", "Name", "ApplicationID", "ObjectID", "Scope", "StartTime", "CacheTimestamp"]
+ : ["Name", "ApplicationID", "ObjectID", "Scope", "StartTime", "CacheTimestamp"];
+
+ return (
+ <>
+
+ {useReportDB && (
+ <>
+
+
+ >
+ )}
+
+
+ : }
+ label={useReportDB ? "Cached" : "Live"}
+ color="primary"
+ size="small"
+ onClick={isAllTenants ? undefined : () => setUseReportDB((prev) => !prev)}
+ clickable={!isAllTenants}
+ disabled={isAllTenants}
+ variant="outlined"
+ />
+
+
+
+ }
+ />
+ {
+ if (response?.Metadata?.QueueId) {
+ setSyncQueueId(response.Metadata.QueueId);
+ }
+ },
+ }}
+ />
+ >
+ );
};
-Page.getLayout = (page) => {page};
+Page.getLayout = (page) => {page};
export default Page;
diff --git a/src/utils/get-cipp-column-size.js b/src/utils/get-cipp-column-size.js
index ecfbd1e1367d..a62c579b8d48 100644
--- a/src/utils/get-cipp-column-size.js
+++ b/src/utils/get-cipp-column-size.js
@@ -23,6 +23,11 @@ export const getCippColumnSize = (accessorKey, header) => {
case 'info.logoUrl':
return { size: 'header', minSize: 'header' }
+ // String arrays that named handlers transform into CippDataTableButton
+ // ("X items" button) — don't measure the raw text.
+ case 'proxyAddresses':
+ return { size: 'header', minSize: 'header' }
+
default:
return null
}