diff --git a/package.json b/package.json index 94b04e986410..40ff2e6d118e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.5.2", + "version": "10.5.5", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/public/version.json b/public/version.json index 326768d361ba..6c7341794d6a 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.5.2" + "version": "10.5.5" } diff --git a/src/components/CippComponents/AuthMethodCard.jsx b/src/components/CippComponents/AuthMethodCard.jsx index 97ade0f8d6c6..d684d8b4e694 100644 --- a/src/components/CippComponents/AuthMethodCard.jsx +++ b/src/components/CippComponents/AuthMethodCard.jsx @@ -16,8 +16,23 @@ export const AuthMethodCard = ({ data, isLoading }) => { return null; } - const phishableMethods = ["mobilePhone", "email", "microsoftAuthenticatorPush"]; - const phishResistantMethods = ["fido2", "windowsHelloForBusiness", "x509Certificate"]; + const phishableMethods = [ + "mobilePhone", + "alternateMobilePhone", + "officePhone", + "email", + "microsoftAuthenticatorPush", + "softwareOneTimePasscode", + "hardwareOneTimePasscode", + ]; + const passkeyMethods = [ + "fido2SecurityKey", + "passKeyDeviceBound", + "passKeyDeviceBoundAuthenticator", + "passKeyDeviceBoundWindowsHello", + "x509Certificate", + ]; + const phishResistantMethods = [...passkeyMethods, "windowsHelloForBusiness"]; let singleFactor = 0; let phishableCount = 0; @@ -48,7 +63,7 @@ export const AuthMethodCard = ({ data, isLoading }) => { if (hasPhishResistant) { phishResistantCount++; - if (methods.includes("fido2") || methods.includes("x509Certificate")) { + if (methods.some((m) => passkeyMethods.includes(m))) { passkeyCount++; } if (methods.includes("windowsHelloForBusiness")) { @@ -56,12 +71,18 @@ export const AuthMethodCard = ({ data, isLoading }) => { } } else if (hasPhishable) { phishableCount++; - if (methods.includes("mobilePhone") || methods.includes("email")) { + if ( + methods.includes("mobilePhone") || + methods.includes("alternateMobilePhone") || + methods.includes("officePhone") || + methods.includes("email") + ) { phoneCount++; } if ( methods.includes("microsoftAuthenticatorPush") || - methods.includes("softwareOneTimePasscode") + methods.includes("softwareOneTimePasscode") || + methods.includes("hardwareOneTimePasscode") ) { authenticatorCount++; } diff --git a/src/components/CippComponents/AuthMethodSankey.jsx b/src/components/CippComponents/AuthMethodSankey.jsx index f57c42573c52..f65ec13c1483 100644 --- a/src/components/CippComponents/AuthMethodSankey.jsx +++ b/src/components/CippComponents/AuthMethodSankey.jsx @@ -13,9 +13,23 @@ export const AuthMethodSankey = ({ data }) => { return null; } - // Categorize MFA methods as phishable or phish-resistant - const phishableMethods = ["mobilePhone", "email", "microsoftAuthenticatorPush"]; - const phishResistantMethods = ["fido2", "windowsHelloForBusiness", "x509Certificate"]; + const phishableMethods = [ + "mobilePhone", + "alternateMobilePhone", + "officePhone", + "email", + "microsoftAuthenticatorPush", + "softwareOneTimePasscode", + "hardwareOneTimePasscode", + ]; + const passkeyMethods = [ + "fido2SecurityKey", + "passKeyDeviceBound", + "passKeyDeviceBoundAuthenticator", + "passKeyDeviceBoundWindowsHello", + "x509Certificate", + ]; + const phishResistantMethods = [...passkeyMethods, "windowsHelloForBusiness"]; let singleFactor = 0; let phishableCount = 0; @@ -54,7 +68,7 @@ export const AuthMethodSankey = ({ data }) => { if (hasPhishResistant) { phishResistantCount++; // Count specific phish-resistant methods - if (methods.includes("fido2") || methods.includes("x509Certificate")) { + if (methods.some((m) => passkeyMethods.includes(m))) { passkeyCount++; } if (methods.includes("windowsHelloForBusiness")) { @@ -62,13 +76,18 @@ export const AuthMethodSankey = ({ data }) => { } } else if (hasPhishable) { phishableCount++; - // Count specific phishable methods - if (methods.includes("mobilePhone") || methods.includes("email")) { + if ( + methods.includes("mobilePhone") || + methods.includes("alternateMobilePhone") || + methods.includes("officePhone") || + methods.includes("email") + ) { phoneCount++; } if ( methods.includes("microsoftAuthenticatorPush") || - methods.includes("softwareOneTimePasscode") + methods.includes("softwareOneTimePasscode") || + methods.includes("hardwareOneTimePasscode") ) { authenticatorCount++; } diff --git a/src/components/CippComponents/CippAppPermissionBuilder.jsx b/src/components/CippComponents/CippAppPermissionBuilder.jsx index 07d21613fb84..013fe843096a 100644 --- a/src/components/CippComponents/CippAppPermissionBuilder.jsx +++ b/src/components/CippComponents/CippAppPermissionBuilder.jsx @@ -462,10 +462,11 @@ const CippAppPermissionBuilder = ({ if (appTable !== undefined && appTable?.length === 0) { setAppTable( spPermissions?.applicationPermissions - ?.sort((a, b) => a.value.localeCompare(b.value)) + ?.sort((a, b) => (a.value ?? "").localeCompare(b.value ?? "")) ?.map((perm) => ({ id: perm.id, value: perm.value, + required: perm.required ?? false, description: spInfo?.Results?.appRoles.find((role) => role.id === perm.id) ?.description, })), @@ -474,10 +475,11 @@ const CippAppPermissionBuilder = ({ if (delegatedTable !== undefined && delegatedTable.length === 0) { setDelegatedTable( spPermissions?.delegatedPermissions - ?.sort((a, b) => a.value.localeCompare(b.value)) + ?.sort((a, b) => (a.value ?? "").localeCompare(b.value ?? "")) ?.map((perm) => ({ id: perm.id, value: perm.value, + required: perm.required ?? false, description: spInfo?.Results?.publishedPermissionScopes.find((scope) => scope.id === perm.id) ?.userConsentDescription ?? "Manually added", @@ -625,6 +627,7 @@ const CippAppPermissionBuilder = ({ label: "Delete Permission", icon: , noConfirm: true, + condition: (row) => !row.required, customFunction: (row) => handleRemoveRow("applicationPermissions", row), }, ]} @@ -690,6 +693,7 @@ const CippAppPermissionBuilder = ({ label: "Delete Permission", icon: , noConfirm: true, + condition: (row) => !row.required, customFunction: (row) => handleRemoveRow("delegatedPermissions", row), }, ]} @@ -788,7 +792,7 @@ const CippAppPermissionBuilder = ({ - + + ) + } + data={results?.Results?.RoleMappingResults} + simpleColumns={["RoleName", "GroupName", "GroupId", "Status", "Message"]} + /> + + )} + {results?.Results?.Memberships?.filter( (membership) => membership?.["@odata.type"] === "#microsoft.graph.group", ).length > 0 && ( diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 284579c2b60d..1d902eb64f90 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -24,6 +24,7 @@ import { CippApiDialog } from '../CippComponents/CippApiDialog' import { getCippError } from '../../utils/get-cipp-error' import { Box } from '@mui/system' import { useSettings } from '../../hooks/use-settings' +import { parseCippDate } from '../../utils/parse-cipp-date' import { isEqual } from 'lodash' // Import lodash for deep comparison import { useLicenseBackfill } from '../../hooks/use-license-backfill' @@ -84,8 +85,8 @@ const SORTING_FNS = { dateTimeNullsLast: (a, b, id) => { const aRaw = getRowValueByColumnId(a, id) const bRaw = getRowValueByColumnId(b, id) - const aDate = aRaw ? new Date(aRaw) : null - const bDate = bRaw ? new Date(bRaw) : null + const aDate = aRaw ? parseCippDate(aRaw) : null + const bDate = bRaw ? parseCippDate(bRaw) : null const aTime = aDate && !Number.isNaN(aDate.getTime()) ? aDate.getTime() : null const bTime = bDate && !Number.isNaN(bDate.getTime()) ? bDate.getTime() : null diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx index 990cb9d35b11..2fc5947c22bf 100644 --- a/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/src/components/CippWizard/CippWizardOffboarding.jsx @@ -12,9 +12,13 @@ import CippWizardStepButtons from './CippWizardStepButtons' import CippFormComponent from '../CippComponents/CippFormComponent' import { CippFormCondition } from '../CippComponents/CippFormCondition' import { useWatch } from 'react-hook-form' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Grid } from '@mui/system' import { useSettings } from '../../hooks/use-settings' +import { ApiGetCall } from '../../api/ApiCall' + +// Shared mailboxes are capped at 50 GiB without a license; warn at 49 GiB. +const SHARED_MAILBOX_WARN_BYTES = 49 * 1024 ** 3 export const CippWizardOffboarding = (props) => { const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props @@ -24,6 +28,40 @@ export const CippWizardOffboarding = (props) => { const userSettingsDefaults = useSettings().userSettingsDefaults const disableForwarding = useWatch({ control: formControl.control, name: 'disableForwarding' }) const deleteUser = useWatch({ control: formControl.control, name: 'DeleteUser' }) + const convertToShared = useWatch({ control: formControl.control, name: 'ConvertToShared' }) + + // Pull cached mailbox sizes (storageUsedInBytes, keyed by UPN) only when relevant + const mailboxUsage = ApiGetCall({ + url: '/api/ListMailboxes', + data: { tenantFilter: currentTenant?.value, UseReportDB: true }, + queryKey: `OffboardingMailboxUsage-${currentTenant?.value}`, + waiting: !!convertToShared && !!currentTenant?.value && selectedUsers?.length > 0, + }) + + // Selected mailboxes whose cached size would exceed the shared-mailbox limit + const oversizedMailboxes = useMemo(() => { + if (!convertToShared || !mailboxUsage.isSuccess || !Array.isArray(mailboxUsage.data)) { + return [] + } + const selectedUpns = (selectedUsers || []).map((u) => + (u?.value ?? u)?.toString().toLowerCase(), + ) + return mailboxUsage.data + .filter((mb) => { + const upn = mb?.UPN?.toString().toLowerCase() + const bytes = Number(mb?.storageUsedInBytes) + return ( + upn && + selectedUpns.includes(upn) && + Number.isFinite(bytes) && + bytes >= SHARED_MAILBOX_WARN_BYTES + ) + }) + .map((mb) => ({ + upn: mb.UPN, + sizeGB: (Number(mb.storageUsedInBytes) / 1024 ** 3).toFixed(1), + })) + }, [convertToShared, mailboxUsage.isSuccess, mailboxUsage.data, selectedUsers]) useEffect(() => { if (selectedUsers.length >= 3) { @@ -383,6 +421,21 @@ export const CippWizardOffboarding = (props) => { formControl={formControl} /> + {convertToShared && oversizedMailboxes.length > 0 && ( + + The following mailbox{oversizedMailboxes.length > 1 ? 'es' : ''} exceed or are near + the 50 GB shared mailbox limit. Converting to shared may fail, or the mailbox may + stop receiving mail once unlicensed, unless an Exchange Online Plan 2 license is + retained: + + {oversizedMailboxes.map((mb) => ( +
  • + {mb.upn} ({mb.sizeGB} GB) +
  • + ))} +
    +
    + )} diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index 15b438b2c608..821233c1eca8 100644 --- a/src/components/PrivateRoute.js +++ b/src/components/PrivateRoute.js @@ -4,6 +4,13 @@ import LoadingPage from "../pages/loading.js"; import ApiOfflinePage from "../pages/api-offline.js"; import { useState, useEffect } from "react"; +// EasyAuth exposes the signed-in identity in two shapes depending on the host: +// - Static Web Apps: { clientPrincipal: { userDetails, userRoles, ... } } +// - App Service EasyAuth: [ { user_id, user_claims: [...], access_token, ... } ] +// an authenticated session must be detected from either populated shape. +const hasAuthenticatedSession = (data) => + Boolean(data?.clientPrincipal) || (Array.isArray(data) && data.length > 0); + export const PrivateRoute = ({ children, routeType }) => { const [unauthLatched, setUnauthLatched] = useState(false); @@ -14,27 +21,26 @@ export const PrivateRoute = ({ children, routeType }) => { staleTime: 120000, // 2 minutes }); - // Latch the unauthenticated state so refetches from child components - // don't flip us back to loading. Clear the latch when session succeeds (after login). + // Latch the unauthenticated state so refetches from child components don't flip us + // back to loading. Latch on a request error or a settled session with no identity; + // clear it as soon as an authenticated session (either shape) is seen. useEffect(() => { if ( !session.isLoading && !session.isFetching && - (session.isError || - null === session?.data?.clientPrincipal || - session?.data === undefined) + (session.isError || !hasAuthenticatedSession(session.data)) ) { setUnauthLatched(true); - } else if (session.isSuccess && session.data?.clientPrincipal) { + } else if (hasAuthenticatedSession(session.data)) { setUnauthLatched(false); } - }, [session.isLoading, session.isFetching, session.isError, session.isSuccess, session.data]); + }, [session.isLoading, session.isFetching, session.isError, session.data]); const apiRoles = ApiGetCall({ url: "/api/me", queryKey: "authmecipp", retry: 2, - waiting: session.isSuccess && session.data?.clientPrincipal !== null, + waiting: session.isSuccess && hasAuthenticatedSession(session.data), }); // If latched as unauthenticated, always show unauthenticated page diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js index 5b99535d1c36..6bc2c53cabb6 100644 --- a/src/components/ReleaseNotesDialog.js +++ b/src/components/ReleaseNotesDialog.js @@ -168,11 +168,7 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const releaseListQuery = ApiGetCall({ url: '/api/ListGitHubReleaseNotes', - queryKey: 'list-github-release-options', - data: { - Owner: RELEASE_OWNER, - Repository: RELEASE_REPO, - }, + queryKey: `list-github-release-options`, waiting: shouldFetchReleaseList, staleTime: 300000, }) @@ -484,7 +480,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { > View release notes on GitHub - + + + {execSamAppPermissions.isLoading && } {execSamAppPermissions.isSuccess && ( { )} + setResetDialogOpen(false)} + title="Reset to CIPP Defaults" + variant="warning" + message="This removes all additional permissions you have layered on top of the CIPP-SAM defaults and returns the saved permission set to the built-in CIPP manifest defaults. The default permissions themselves are unaffected. You will need to complete a Permissions repair from the Permissions page, then complete a CPV refresh to finalise the chnages. Continue?" + onConfirm={() => { + handleResetToCippDefaults(); + setResetDialogOpen(false); + }} + /> ); }; diff --git a/src/pages/email/administration/contacts-template/add.jsx b/src/pages/email/administration/contacts-template/add.jsx index b05da569e29e..f43bf99d7721 100644 --- a/src/pages/email/administration/contacts-template/add.jsx +++ b/src/pages/email/administration/contacts-template/add.jsx @@ -37,27 +37,27 @@ const AddContactTemplates = () => { resetForm={true} customDataformatter={(values) => { return { - DisplayName: values.displayName, + displayName: values.displayName, hidefromGAL: values.hidefromGAL, email: values.email, - FirstName: values.firstName, - LastName: values.lastName, - Title: values.jobTitle, - StreetAddress: values.streetAddress, - PostalCode: values.postalCode, - City: values.city, - State: values.state, - CountryOrRegion: values.country?.value || values.country, - Company: values.companyName, + firstName: values.firstName, + lastName: values.lastName, + jobTitle: values.jobTitle, + streetAddress: values.streetAddress, + postalCode: values.postalCode, + city: values.city, + state: values.state, + country: values.country?.value || values.country, + companyName: values.companyName, mobilePhone: values.mobilePhone, - phone: values.businessPhone, + businessPhone: values.businessPhone, website: values.website, mailTip: values.mailTip, }; }} > - ); diff --git a/src/pages/email/administration/contacts-template/edit.jsx b/src/pages/email/administration/contacts-template/edit.jsx index 987e9f45a3bb..33f40120a1c2 100644 --- a/src/pages/email/administration/contacts-template/edit.jsx +++ b/src/pages/email/administration/contacts-template/edit.jsx @@ -8,8 +8,6 @@ import { ApiGetCall } from "../../../../api/ApiCall"; import countryList from "../../../../data/countryList.json"; import { useRouter } from "next/router"; -const countryLookup = new Map(countryList.map((country) => [country.Name, country.Code])); - const EditContactTemplate = () => { const router = useRouter(); const { id } = router.query; @@ -57,32 +55,33 @@ const EditContactTemplate = () => { const contact = Array.isArray(contactTemplateInfo.data) ? contactTemplateInfo.data[0] : contactTemplateInfo.data; - const address = contact.addresses?.[0] || {}; - const phones = contact.phones || []; - // Use Map for O(1) phone lookup - const phoneMap = new Map(phones.map((p) => [p.type, p.number])); + // The template is stored as a flat object (see Invoke-AddContactTemplates), so read the + // fields directly rather than treating it as a Microsoft Graph contact. + const countryEntry = contact.country + ? countryList.find((c) => c.Code === contact.country || c.Name === contact.country) + : null; return { ContactTemplateID: id || "", displayName: contact.displayName || "", - firstName: contact.givenName || "", - lastName: contact.surname || "", + firstName: contact.firstName || "", + lastName: contact.lastName || "", email: contact.email || "", hidefromGAL: contact.hidefromGAL || false, - streetAddress: address.street || "", - postalCode: address.postalCode || "", - city: address.city || "", - state: address.state || "", - country: address.countryOrRegion ? countryLookup.get(address.countryOrRegion) || "" : "", + streetAddress: contact.streetAddress || "", + postalCode: contact.postalCode || "", + city: contact.city || "", + state: contact.state || "", + country: countryEntry ? { label: countryEntry.Name, value: countryEntry.Code } : "", companyName: contact.companyName || "", - mobilePhone: phoneMap.get("mobile") || "", - businessPhone: phoneMap.get("business") || "", + mobilePhone: contact.mobilePhone || "", + businessPhone: contact.businessPhone || "", jobTitle: contact.jobTitle || "", website: contact.website || "", mailTip: contact.mailTip || "", }; - }, [contactTemplateInfo.isSuccess, contactTemplateInfo.data]); + }, [contactTemplateInfo.isSuccess, contactTemplateInfo.data, id]); // Use callback to prevent unnecessary re-renders const resetForm = useCallback(() => { @@ -96,27 +95,30 @@ const EditContactTemplate = () => { }, [resetForm]); // Memoize custom data formatter - const customDataFormatter = useCallback((values) => { - return { - ContactTemplateID: id, - DisplayName: values.displayName, - hidefromGAL: values.hidefromGAL, - email: values.email, - FirstName: values.firstName, - LastName: values.lastName, - Title: values.jobTitle, - StreetAddress: values.streetAddress, - PostalCode: values.postalCode, - City: values.city, - State: values.state, - CountryOrRegion: values.country?.value || values.country, - Company: values.companyName, - mobilePhone: values.mobilePhone, - phone: values.businessPhone, - website: values.website, - mailTip: values.mailTip, - }; - }); + const customDataFormatter = useCallback( + (values) => { + return { + ContactTemplateID: id, + displayName: values.displayName, + hidefromGAL: values.hidefromGAL, + email: values.email, + firstName: values.firstName, + lastName: values.lastName, + jobTitle: values.jobTitle, + streetAddress: values.streetAddress, + postalCode: values.postalCode, + city: values.city, + state: values.state, + country: values.country?.value || values.country, + companyName: values.companyName, + mobilePhone: values.mobilePhone, + businessPhone: values.businessPhone, + website: values.website, + mailTip: values.mailTip, + }; + }, + [id] + ); const contactTemplate = Array.isArray(contactTemplateInfo.data) ? contactTemplateInfo.data[0] diff --git a/src/pages/email/administration/contacts-template/index.jsx b/src/pages/email/administration/contacts-template/index.jsx index d24604c9a9af..8d5fc6d69239 100644 --- a/src/pages/email/administration/contacts-template/index.jsx +++ b/src/pages/email/administration/contacts-template/index.jsx @@ -81,7 +81,14 @@ const Page = () => { target: "_self", }, ]; - const simpleColumns = ["name", "contactTemplateName", "GUID"]; + const simpleColumns = [ + "displayName", + "email", + "companyName", + "jobTitle", + "hidefromGAL", + "GUID", + ]; return ( { simpleColumns={simpleColumns} cardButton={ <> - + + + + ) +} const Page = () => { const pageTitle = 'SharePoint Sites' @@ -242,7 +347,10 @@ const Page = () => { name="DeleteOlderThanDays" label="Delete Versions Older Than (days)" formControl={formHook} - validators={{ required: 'Please enter the number of days' }} + validators={{ + required: 'Please enter the number of days', + min: { value: 30, message: 'SharePoint requires at least 30 days' }, + }} /> { formControl={formHook} validators={{ required: 'Please enter the version limit' }} /> + ), defaultvalues: { BatchDeleteMode: '2', }, - customDataformatter: (row, action, formData) => ({ - tenantFilter: row.Tenant ?? tenantFilter, - SiteUrl: row.webUrl, - BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10), - DeleteOlderThanDays: - formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, - MajorVersionLimit: - formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, - }), + customDataformatter: (row, action, formData) => { + const formatRow = (singleRow) => ({ + tenantFilter: singleRow.Tenant ?? tenantFilter, + SiteUrl: singleRow.webUrl, + BatchDeleteMode: parseInt(formData.BatchDeleteMode, 10), + DeleteOlderThanDays: + formData.BatchDeleteMode === '0' ? parseInt(formData.DeleteOlderThanDays, 10) : -1, + MajorVersionLimit: + formData.BatchDeleteMode === '1' ? parseInt(formData.MajorVersionLimit, 10) : -1, + MajorWithMinorVersionsLimit: + formData.BatchDeleteMode === '1' + ? parseInt(formData.MajorWithMinorVersionsLimit, 10) + : -1, + }) + // When multiple rows are selected, row is an array. Returning an array + // makes CippApiDialog send one request per row (bulk request mode). + return Array.isArray(row) ? row.map(formatRow) : formatRow(row) + }, + multiPost: false, + }, + { + label: 'Check Cleanup Job Status', + icon: , + customComponent: (row, { drawerVisible, setDrawerVisible }) => ( + + ), multiPost: false, }, ] diff --git a/src/pages/teams-share/teams/business-voice/index.js b/src/pages/teams-share/teams/business-voice/index.js index 3c9354706147..89f2d298ac76 100644 --- a/src/pages/teams-share/teams/business-voice/index.js +++ b/src/pages/teams-share/teams/business-voice/index.js @@ -35,7 +35,16 @@ const Page = () => { multiple: false, creatable: false, api: { - url: "/api/listUsers", + url: "/api/ListGraphRequest", + dataKey: "Results", + data: { + Endpoint: "users", + manualPagination: true, + $select: "id,userPrincipalName,displayName", + $count: true, + $orderby: "displayName", + $top: 999, + }, labelField: (input) => `${input.displayName} (${input.userPrincipalName})`, valueField: "userPrincipalName", }, diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js index 97bcd6346c36..59b01b6ec7f4 100644 --- a/src/pages/tenant/manage/drift.js +++ b/src/pages/tenant/manage/drift.js @@ -1948,11 +1948,11 @@ const ManageDriftPage = () => { onClick={() => handleBulkAction('accept-all-customer-specific')} > - Accept All Deviations - Customer Specific + Accept Selected Deviations - Customer Specific handleBulkAction('accept-all')}> - Accept All Deviations + Accept Selected Deviations {/* Only show delete option if there are template deviations that support deletion */} {processedDriftData.currentDeviations.some( @@ -1965,12 +1965,12 @@ const ManageDriftPage = () => { ) && ( handleBulkAction('deny-all-delete')}> - Deny All Deviations - Delete + Deny Selected Deviations - Delete )} handleBulkAction('deny-all-remediate')}> - Deny All Deviations - Remediate to align with template + Deny Selected Deviations - Remediate to align with template @@ -2068,6 +2068,7 @@ const ManageDriftPage = () => { type: 'textField', name: 'reason', label: 'Reason for change (Mandatory)', + required: true, }, ...(actionData.data?.deviations?.some((d) => d.status === 'DeniedRemediate') ? [ diff --git a/src/pages/tools/custom-tests/add.jsx b/src/pages/tools/custom-tests/add.jsx index e4da31a623c5..9762f797cb01 100644 --- a/src/pages/tools/custom-tests/add.jsx +++ b/src/pages/tools/custom-tests/add.jsx @@ -118,7 +118,7 @@ const Page = () => { ScriptContent: '', Enabled: false, AlertOnFailure: false, - AlertStatuses: [{ value: 'Failed', label: 'Failed' }], + AlertStatuses: { value: 'Failed', label: 'Failed' }, ReturnType: 'JSON', ResultMode: { value: 'Auto', label: 'Auto' }, MarkdownTemplate: '', @@ -148,12 +148,7 @@ const Page = () => { ScriptContent: script.ScriptContent || '', Enabled: script.Enabled || false, AlertOnFailure: script.AlertOnFailure || false, - AlertStatuses: script.AlertStatuses - ? (typeof script.AlertStatuses === 'string' - ? JSON.parse(script.AlertStatuses) - : script.AlertStatuses - ).map((s) => ({ value: s, label: s })) - : [{ value: 'Failed', label: 'Failed' }], + AlertStatuses: toSelectOption(script.AlertStatuses, 'Failed'), ReturnType: script.ReturnType || 'JSON', ResultMode: toSelectOption(script.ResultMode, 'Auto'), MarkdownTemplate: script.MarkdownTemplate || '', @@ -261,9 +256,7 @@ const Page = () => { ScriptContent: data.ScriptContent, Enabled: data.Enabled, AlertOnFailure: data.AlertOnFailure, - AlertStatuses: data.AlertOnFailure - ? (data.AlertStatuses?.map(s => s.value) || ['Failed']) - : [], + AlertStatuses: data.AlertStatuses?.value ?? data.AlertStatuses, ReturnType: data.ReturnType, ResultMode: data.ResultMode?.value ?? data.ResultMode, MarkdownTemplate: data.MarkdownTemplate, @@ -325,6 +318,14 @@ const Page = () => { { value: 'AlwaysInvestigate', label: 'Always Investigate' }, ] + const AlertStatuses = [ + { value: 'Failed', label: 'Failed' }, + { value: 'Passed', label: 'Passed' }, + { value: 'Info', label: 'Info' }, + { value: 'Investigate', label: 'Investigate' }, + { value: 'All', label: 'All' }, + ] + const scriptNameField = { name: 'ScriptName', label: 'Script Name', @@ -415,14 +416,8 @@ const Page = () => { const alertStatusesField = { name: 'AlertStatuses', label: 'Alert on Status', - type: 'autoComplete', - multiple: true, - options: [ - { label: 'Failed', value: 'Failed' }, - { label: 'Passed', value: 'Passed' }, - { label: 'Info', value: 'Info' }, - { label: 'Investigate', value: 'Investigate' }, - ], + type: 'select', + options: AlertStatuses, helperText: 'Choose which test result statuses trigger an alert.', } @@ -814,10 +809,12 @@ All UPNs: {{join(Result[*].UserPrincipalName, ", ")}}`, - AST allowlist — approved cmdlets only. += is blocked. Data access - is automatically tenant-locked — do not pass{' '} - -TenantFilter. Type % in the editor for replacement - variables. + Runs in PowerShell ConstrainedLanguage — approved cmdlets + only. New-Object, {'[pscustomobject]@{}'} casts, and + .NET/reflection are blocked. Build rows with{' '} + {'Select-Object @{Name;Expression}'} and return a plain{' '} + {'@{}'} hashtable. Data access is tenant-locked — do not pass{' '} + -TenantFilter. Type % for replacement variables. @@ -907,20 +904,11 @@ $Licenses | ForEach-Object { # Build results - users with their resolved license names $results = $Users | Where-Object { $_.assignedLicenses.Count -gt 0 -} | ForEach-Object { - $user = $_ - $licenseNames = @($user.assignedLicenses | ForEach-Object { - $name = $SkuLookup[$_.skuId] - if ($name) { $name } else { $_.skuId } - }) - [PSCustomObject]@{ - UserPrincipalName = $user.userPrincipalName - DisplayName = $user.displayName - AccountEnabled = $user.accountEnabled - LicenseCount = $licenseNames.Count - Licenses = $licenseNames -join ', ' - } -} +} | Select-Object @{Name='UserPrincipalName'; Expression={ $_.userPrincipalName }}, + @{Name='DisplayName'; Expression={ $_.displayName }}, + @{Name='AccountEnabled'; Expression={ $_.accountEnabled }}, + @{Name='LicenseCount'; Expression={ @($_.assignedLicenses).Count }}, + @{Name='Licenses'; Expression={ (@($_.assignedLicenses | ForEach-Object { $n = $SkuLookup[$_.skuId]; if ($n) { $n } else { $_.skuId } }) -join ', ') }} # Build markdown table $header = "### Licensed Users: $($results.Count)\\n\\n| User | Display Name | Enabled | Licenses |\\n|---|---|---|---|" @@ -968,14 +956,10 @@ $Users = Get-CIPPTestData -Type 'Users' $Users | Where-Object { $_.accountEnabled -eq $false -and $_.assignedLicenses.Count -gt 0 -} | ForEach-Object { - [PSCustomObject]@{ - UserPrincipalName = $_.userPrincipalName - DisplayName = $_.displayName - LicenseCount = $_.assignedLicenses.Count - Message = 'Disabled account with active license(s)' - } -}`} +} | Select-Object @{Name='UserPrincipalName'; Expression={ $_.userPrincipalName }}, + @{Name='DisplayName'; Expression={ $_.displayName }}, + @{Name='LicenseCount'; Expression={ @($_.assignedLicenses).Count }}, + @{Name='Message'; Expression={ 'Disabled account with active license(s)' }}`} language="powershell" showLineNumbers={true} /> @@ -1008,14 +992,10 @@ $RegDetails = Get-CIPPTestData -Type 'UserRegistrationDetails' $noMfa = $RegDetails | Where-Object { $_.methodsRegistered.Count -eq 0 -and $_.userType -ne 'guest' -} | ForEach-Object { - [PSCustomObject]@{ - UserPrincipalName = $_.userPrincipalName - UserDisplayName = $_.userDisplayName - IsAdmin = $_.isAdmin - Message = 'No MFA methods registered' - } -} +} | Select-Object @{Name='UserPrincipalName'; Expression={ $_.userPrincipalName }}, + @{Name='UserDisplayName'; Expression={ $_.userDisplayName }}, + @{Name='IsAdmin'; Expression={ $_.isAdmin }}, + @{Name='Message'; Expression={ 'No MFA methods registered' }} $count = @($noMfa).Count if ($count -gt 0) { @@ -1068,18 +1048,11 @@ $cutoff = (Get-Date).AddDays(-$DaysThreshold) $Guests | Where-Object { -not $_.signInActivity.lastSignInDateTime -or [datetime]$_.signInActivity.lastSignInDateTime -lt $cutoff -} | ForEach-Object { - $lastSign = if ($_.signInActivity.lastSignInDateTime) { - $_.signInActivity.lastSignInDateTime - } else { 'Never' } - [PSCustomObject]@{ - UserPrincipalName = $_.userPrincipalName - DisplayName = $_.displayName - CreatedDateTime = $_.createdDateTime - LastSignIn = $lastSign - Message = "No sign-in within $DaysThreshold days" - } -}`} +} | Select-Object @{Name='UserPrincipalName'; Expression={ $_.userPrincipalName }}, + @{Name='DisplayName'; Expression={ $_.displayName }}, + @{Name='CreatedDateTime'; Expression={ $_.createdDateTime }}, + @{Name='LastSignIn'; Expression={ if ($_.signInActivity.lastSignInDateTime) { $_.signInActivity.lastSignInDateTime } else { 'Never' } }}, + @{Name='Message'; Expression={ "No sign-in within $DaysThreshold days" }}`} language="powershell" showLineNumbers={true} /> @@ -1111,12 +1084,8 @@ $Guests | Where-Object { $Policies = Get-CIPPTestData -Type 'ConditionalAccessPolicies' $grouped = $Policies | Group-Object -Property state -$counts = $grouped | ForEach-Object { - [PSCustomObject]@{ - State = $_.Name - Count = $_.Count - } -} +$counts = $grouped | Select-Object @{Name='State'; Expression={ $_.Name }}, + @{Name='Count'; Expression={ $_.Count }} # Build markdown summary — %tenantname% is replaced at runtime $header = "### %tenantname% — CA Policies: $(@($Policies).Count) total\n\n| State | Count |\n|---|---|" @@ -1323,6 +1292,7 @@ $md = $summaryTable + "\n\n---\n\n" + $policyTable formControl={formControl} compareType="is" compareValue={true} + clearOnHide={false} > % to insert replacement variables (e.g.{' '} %tenantid%, %defaultdomain%, or custom variables). + + Scripts run in ConstrainedLanguage. Build output rows with{' '} + {'Select-Object @{Name;Expression}'} (not{' '} + {'[pscustomobject]@{}'}) and return a{' '} + {'@{ CIPPStatus = ... }'} hashtable. New-Object and + .NET reflection are blocked. + {hasTenantFilterParam && ( -TenantFilter is not needed — data access functions are diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 3c6b6a066031..e42846b34db3 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -35,6 +35,7 @@ import { getSignInErrorCodeTranslation } from './get-cipp-signin-errorcode-trans import { CollapsibleChipList } from '../components/CippComponents/CollapsibleChipList' import countryList from '../data/countryList.json' import { getStandards } from './standards-data' +import { parseCippDate } from './parse-cipp-date' // Helper function to convert country codes to country names const getCountryNameFromCode = (countryCode) => { @@ -217,9 +218,9 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr const matchDateTime = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/ if (timeAgoArray.includes(cellName) || matchDateTime.test(cellName)) { return isText && canReceive === false ? ( - new Date(data).toLocaleString() // This runs if canReceive is false and isText is true + parseCippDate(data).toLocaleString() // This runs if canReceive is false and isText is true ) : isText && canReceive !== 'both' ? ( - new Date(data) // This runs if isText is true and canReceive is not "both" or false + parseCippDate(data) // This runs if isText is true and canReceive is not "both" or false ) : ( ) diff --git a/src/utils/parse-cipp-date.js b/src/utils/parse-cipp-date.js new file mode 100644 index 000000000000..f9ed14f41d4e --- /dev/null +++ b/src/utils/parse-cipp-date.js @@ -0,0 +1,16 @@ +// Parse a date value coming from the CIPP API into a JS Date. +// +// Some backend tables (e.g. ScheduledTasks / Extension Sync) store timestamps as a +// Unix epoch in *seconds*, often cast to a string (e.g. "1719225600"). Passing that +// straight to `new Date()` yields an Invalid Date, which silently breaks table +// sorting and date-range filtering. Numeric / all-digit values are therefore treated +// as epoch seconds and multiplied by 1000; everything else (ISO 8601 strings, etc.) +// is passed through to the native Date parser. +const allDigits = /^\d+$/ + +export const parseCippDate = (data) => { + if (typeof data === 'number' || (typeof data === 'string' && allDigits.test(data))) { + return new Date(Number(data) * 1000) + } + return new Date(data) +}