diff --git a/package.json b/package.json index 94b04e986410..2f634635ef99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.5.2", + "version": "10.5.3", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -116,4 +116,4 @@ "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.1" } -} +} \ No newline at end of file diff --git a/public/version.json b/public/version.json index 326768d361ba..1ff943c0e53a 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.5.2" + "version": "10.5.3" } diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 075f47ed29a1..04dd59da8e44 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -146,7 +146,10 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { const currentTenant = api?.tenantFilter ? api.tenantFilter : useSettings().currentTenant useEffect(() => { if (actionGetRequest.isSuccess && !actionGetRequest.isFetching) { - const lastPage = actionGetRequest.data?.pages[actionGetRequest.data.pages.length - 1] + // Guard against a non-paginated cache shape (e.g. when a queryKey is accidentally shared + // with a useQuery/ApiGetCall consumer that stores a plain array instead of { pages }). + const pages = actionGetRequest.data?.pages + const lastPage = Array.isArray(pages) ? pages[pages.length - 1] : undefined const nextLinkExists = lastPage?.Metadata?.nextLink if (nextLinkExists) { actionGetRequest.fetchNextPage() 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/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..15a5a52782c1 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, }) diff --git a/src/components/CippSettings/CippGDAPResults.jsx b/src/components/CippSettings/CippGDAPResults.jsx index 46d505a4535d..306c7451eb32 100644 --- a/src/components/CippSettings/CippGDAPResults.jsx +++ b/src/components/CippSettings/CippGDAPResults.jsx @@ -1,15 +1,34 @@ -import { Alert, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material"; +import { Alert, Button, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material"; import { Cancel, CheckCircle, Warning } from "@mui/icons-material"; import { CippPropertyList } from "../CippComponents/CippPropertyList"; -import { XMarkIcon } from "@heroicons/react/24/outline"; +import { WrenchIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippDataTable } from "../CippTable/CippDataTable"; +import { ApiPostCall } from "../../api/ApiCall"; +import { CippApiResults } from "../CippComponents/CippApiResults"; import { useEffect, useState } from "react"; export const CippGDAPResults = (props) => { 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 + + ) + } + 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/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/data/CIPPDBCacheTypes.json b/src/data/CIPPDBCacheTypes.json index 0d0588d2b624..f86f7d59b03d 100644 --- a/src/data/CIPPDBCacheTypes.json +++ b/src/data/CIPPDBCacheTypes.json @@ -328,5 +328,10 @@ "type": "DetectedApps", "friendlyName": "Detected Apps", "description": "All detected applications with devices where each app is installed" + }, + { + "type": "IntuneAppInstallStatus", + "friendlyName": "Intune App Install Status", + "description": "Per-application install status rollup (failed/installed/pending device counts) from the AppInstallStatusAggregate report" } ] diff --git a/src/data/alerts.json b/src/data/alerts.json index e0f7ffc9b9a8..9c907c4f5e92 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -391,7 +391,7 @@ { "name": "IntunePolicyConflicts", "label": "Alert on Intune policy or app conflicts/errors", - "recommendedRunInterval": "4h", + "recommendedRunInterval": "1d", "requiresInput": true, "multipleInput": true, "inputs": [ diff --git a/src/data/standards.json b/src/data/standards.json index a698010ce141..3d27a41de832 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -8085,8 +8085,12 @@ { "type": "number", "name": "standards.SPOVersionControl.ExpireVersionsAfterDays", - "label": "Expire Versions After Days (0 = never, when auto trim is off)", - "default": 0 + "label": "Expire Versions After Days (0 = never, otherwise 30-36500, when auto trim is off)", + "default": 0, + "validators": { + "min": { "value": 0, "message": "Use 0 for never, or 30 or more days" }, + "max": { "value": 36500, "message": "Maximum value is 36500" } + } }, { "type": "switch", diff --git a/src/hooks/use-user-bookmarks.js b/src/hooks/use-user-bookmarks.js index 7427ea5c06f0..bbb977522942 100644 --- a/src/hooks/use-user-bookmarks.js +++ b/src/hooks/use-user-bookmarks.js @@ -1,9 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useMemo } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { ApiGetCall, ApiPostCall } from "../api/ApiCall"; -const SETTINGS_STORAGE_KEY = "app.settings"; - const sanitizeBookmark = (bookmark) => { if (!bookmark || typeof bookmark !== "object") { return null; @@ -43,47 +41,6 @@ const normalizeBookmarks = (value) => { return []; }; -const getLocalStoredBookmarks = () => { - if (typeof window === "undefined") { - return []; - } - - try { - const restored = window.localStorage.getItem(SETTINGS_STORAGE_KEY); - if (!restored) { - return []; - } - - const parsed = JSON.parse(restored); - return normalizeBookmarks(parsed?.bookmarks); - } catch { - return []; - } -}; - -const clearLocalStoredBookmarks = () => { - if (typeof window === "undefined") { - return; - } - - try { - const restored = window.localStorage.getItem(SETTINGS_STORAGE_KEY); - if (!restored) { - return; - } - - const parsed = JSON.parse(restored); - if (!parsed || typeof parsed !== "object" || !Object.prototype.hasOwnProperty.call(parsed, "bookmarks")) { - return; - } - - delete parsed.bookmarks; - window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(parsed)); - } catch { - return; - } -}; - const getBookmarksFromSettings = (settingsData) => { if (!settingsData) { return []; @@ -102,8 +59,6 @@ const getBookmarksFromSettings = (settingsData) => { export const useUserBookmarks = () => { const queryClient = useQueryClient(); - const localMigrationComplete = useRef(false); - const localMigrationInFlight = useRef(false); const userSettings = ApiGetCall({ url: "/api/ListUserSettings", @@ -163,47 +118,10 @@ export const useUserBookmarks = () => { [persistBookmarks] ); - useEffect(() => { - if (localMigrationComplete.current || localMigrationInFlight.current) { - return; - } - - if (!auth.data?.clientPrincipal?.userDetails) { - return; - } - - if (bookmarks.length > 0) { - localMigrationComplete.current = true; - return; - } - - const localBookmarks = getLocalStoredBookmarks(); - if (localBookmarks.length === 0) { - localMigrationComplete.current = true; - return; - } - - localMigrationInFlight.current = true; - const didPost = persistBookmarks(localBookmarks, { - onSuccess: () => { - clearLocalStoredBookmarks(); - localMigrationInFlight.current = false; - localMigrationComplete.current = true; - }, - onError: () => { - localMigrationInFlight.current = false; - }, - }); - - if (!didPost) { - localMigrationInFlight.current = false; - } - }, [auth.data?.clientPrincipal?.userDetails, bookmarks.length, persistBookmarks]); - return { bookmarks, setBookmarks, isLoading: userSettings.isLoading, isSaving: saveBookmarksPost.isPending, }; -}; \ No newline at end of file +}; diff --git a/src/layouts/index.js b/src/layouts/index.js index f3c178556ff3..0ee521c51313 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -180,6 +180,7 @@ export const Layout = (props) => { // check sub-items if (item.items && item.items.length > 0) { const filteredSubItems = filterItemsByRole(item.items).filter(Boolean) + if (filteredSubItems.length === 0) return null return { ...item, items: filteredSubItems } } diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js index 04f4a978609a..0ae0ec7abdec 100644 --- a/src/layouts/side-nav-bookmarks.js +++ b/src/layouts/side-nav-bookmarks.js @@ -522,16 +522,18 @@ export const SideNavBookmarks = ({ collapse = false }) => { )} - { - e.preventDefault(); - removeBookmark(bookmark.path); - }} - sx={{ p: "2px" }} - > - - + {!locked && ( + { + e.preventDefault(); + removeBookmark(bookmark.path); + }} + sx={{ p: "2px" }} + > + + + )} diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 10bdcefd9581..fb5c7483e20f 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -54,7 +54,7 @@ export const TopNav = (props) => { const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) const showPopoverBookmarks = settings.bookmarkPopover === true const reorderMode = settings.bookmarkReorderMode || 'arrows' - const locked = settings.bookmarkLocked ?? false + const locked = settings.bookmarkLocked ?? true const handleThemeSwitch = useCallback(() => { const themeName = settings.currentTheme?.value === 'light' ? 'dark' : 'light' settings.handleUpdate({ @@ -590,18 +590,13 @@ export const TopNav = (props) => { )} - {!(reorderMode === 'drag' && locked) && ( + {!locked && ( { e.preventDefault() - if (locked) { - triggerLockFlash() - return - } removeBookmark(bookmark.path) }} - sx={{ ...(locked && { opacity: 0.4 }) }} > diff --git a/src/pages/cipp/advanced/super-admin/cipp-users.js b/src/pages/cipp/advanced/super-admin/cipp-users.js index 32f0534825b2..ad3b6097c147 100644 --- a/src/pages/cipp/advanced/super-admin/cipp-users.js +++ b/src/pages/cipp/advanced/super-admin/cipp-users.js @@ -14,9 +14,15 @@ const Page = () => { Manage users who can access CIPP. Users are automatically synced from your partner tenant every 15 minutes based on Entra group memberships configured on the CIPP Roles page. You can also manually add users or assign additional roles — manual assignments - are preserved independently and will not be overwritten by the sync. Users not in this - list can still log in if "Allow All Tenant Users" is enabled, but they will - only receive default (authenticated) permissions. + are preserved independently and will not be overwritten by the sync. Users assigned the + superadmin role have full access to CIPP and all other permissions applied will be ignored. + You must have at least one superadmin user in CIPP at all times, and you cannot remove the + superadmin role from a user if they are the only superadmin. If you have only one superadmin + and need to change who it is, first assign another user the superadmin role, then you can + remove the superadmin role from the original user. To allow users from outside your partner tenant + to access CIPP, you can add them as guest users in your partner tenant and assign them the + appropriate roles in CIPP or enable the multi tenant mode in the CIPP SSO tab and add the users + to the list below without needing to add them as guest users in your tenant. diff --git a/src/pages/security/compliance/labels-templates/index.js b/src/pages/security/compliance/labels-templates/index.js index 78477f7735b5..092d076f5370 100644 --- a/src/pages/security/compliance/labels-templates/index.js +++ b/src/pages/security/compliance/labels-templates/index.js @@ -72,9 +72,9 @@ const Page = () => { const offCanvas = { extendedInfoFields: [ - "name", "DisplayName", - "comments", + "Name", + "Comment", "ContentType", "EncryptionEnabled", "GUID", @@ -82,7 +82,7 @@ const Page = () => { actions: actions, }; - const simpleColumns = ["name", "DisplayName", "comments", "ContentType", "EncryptionEnabled", "GUID"]; + const simpleColumns = ["DisplayName", "Name", "Comment", "ContentType", "EncryptionEnabled", "GUID"]; return ( { + const progress = statusApi.data?.Results + + if (statusApi.isError) { + return Failed to load cleanup job status. + } + + // No job: either an empty/blank response, or the API's explicit "NoRequestFound" status. + if ( + !statusApi.isFetching && + (progress === undefined || + progress === null || + (typeof progress === 'string' && progress.trim() === '') || + progress?.Status === 'NoRequestFound') + ) { + return No cleanup job found for this site. + } + + // Backend couldn't parse the payload and returned the raw string. + if (!statusApi.isFetching && typeof progress === 'string') { + return {progress} + } + + const propertyItems = VERSION_CLEANUP_FIELDS.filter( + (key) => progress?.[key] !== undefined && progress?.[key] !== '', + ).map((key) => ({ + label: VERSION_CLEANUP_LABELS[key], + value: String(progress[key]), + })) + + return ( + ({ label: VERSION_CLEANUP_LABELS[key], value: '' })) + } + /> + ) +} + +// Custom-component action modal: opens directly (no confirmation step) and fetches the trim +// job status for the selected site, rendering it as a property list. +const VersionCleanupStatusModal = ({ row, tenantFilter, drawerVisible, setDrawerVisible }) => { + const siteRow = Array.isArray(row) ? row[0] : row + const siteUrl = siteRow?.webUrl + const statusApi = ApiGetCall({ + url: '/api/ListSPOVersionCleanup', + data: { + tenantFilter: siteRow?.Tenant ?? tenantFilter, + SiteUrl: siteUrl, + }, + queryKey: `SPOVersionCleanupStatus-${siteUrl}`, + waiting: !!drawerVisible && !!siteUrl, + }) + + return ( + setDrawerVisible(false)} + > + + Cleanup Job Status{siteRow?.displayName ? ` — ${siteRow.displayName}` : ''} + + + + + + + + + ) +} 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/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') ? [