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 }