diff --git a/package.json b/package.json index 28408a505e59..a355bff79afa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "8.7.0", + "version": "8.7.1", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -89,8 +89,6 @@ "react-leaflet": "5.0.0", "react-leaflet-markercluster": "^5.0.0-rc.0", "react-markdown": "10.1.0", - "rehype-raw": "^7.0.0", - "remark-gfm": "^3.0.1", "react-media-hook": "^0.5.0", "react-papaparse": "^4.4.0", "react-quill": "^2.0.0", @@ -103,6 +101,8 @@ "redux-devtools-extension": "2.13.9", "redux-persist": "^6.0.0", "redux-thunk": "3.1.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^3.0.1", "simplebar": "6.3.2", "simplebar-react": "3.3.2", "stylis-plugin-rtl": "2.1.1", @@ -114,4 +114,4 @@ "eslint": "9.35.0", "eslint-config-next": "15.5.2" } -} +} \ No newline at end of file diff --git a/public/version.json b/public/version.json index 25ebf6510f79..1194adaf2480 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "8.7.0" -} + "version": "8.7.1" +} \ No newline at end of file diff --git a/src/components/CippCards/CippPageCard.jsx b/src/components/CippCards/CippPageCard.jsx index 004f34965127..07b278b4bdfb 100644 --- a/src/components/CippCards/CippPageCard.jsx +++ b/src/components/CippCards/CippPageCard.jsx @@ -25,27 +25,12 @@ const CippPageCard = (props) => { -
- {!hideBackButton && ( - - )} -
{hideTitleText !== true && (
{title} diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index e45fe1a22365..05af78d5573e 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -41,7 +41,7 @@ export const CippApiDialog = (props) => { } const formHook = useForm({ - defaultValues: defaultvalues || {}, + defaultValues: typeof defaultvalues === "function" ? defaultvalues(row) : defaultvalues || {}, mode: "onChange", // Enable real-time validation }); diff --git a/src/components/CippComponents/CippApiLogsDrawer.jsx b/src/components/CippComponents/CippApiLogsDrawer.jsx index 12e9e9e93a61..67eb80cef47e 100644 --- a/src/components/CippComponents/CippApiLogsDrawer.jsx +++ b/src/components/CippComponents/CippApiLogsDrawer.jsx @@ -10,6 +10,7 @@ export const CippApiLogsDrawer = ({ apiFilter = null, tenantFilter = null, standardFilter = null, + scheduledTaskFilter = null, requiredPermissions = [], PermissionButton = Button, title = "API Logs", @@ -28,7 +29,9 @@ export const CippApiLogsDrawer = ({ // Build the API URL with the filter const apiUrl = `/api/ListLogs?Filter=true${apiFilter ? `&API=${apiFilter}` : ""}${ tenantFilter ? `&Tenant=${tenantFilter}` : "" - }${standardFilter ? `&StandardTemplateId=${standardFilter}` : ""}`; + }${standardFilter ? `&StandardTemplateId=${standardFilter}` : ""}${ + scheduledTaskFilter ? `&ScheduledTaskId=${scheduledTaskFilter}` : "" + }`; // Define the columns for the logs table const simpleColumns = [ @@ -74,7 +77,9 @@ export const CippApiLogsDrawer = ({ url: apiUrl, dataKey: "", }} - queryKey={`APILogs-${apiFilter || "All"}`} + queryKey={`APILogs-${apiFilter || "All"}-${tenantFilter || "AllTenants"}-${ + standardFilter || "NoStandard" + }-${scheduledTaskFilter || "NoTask"}`} simpleColumns={simpleColumns} exportEnabled={true} offCanvas={{ diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index 388b27984ced..124f4282e1af 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -158,8 +158,27 @@ export const CippApiResults = (props) => { const allResults = useMemo(() => { const apiResults = extractAllResults(correctResultObj); + + // Also extract error results if there's an error + if (apiObject.isError && apiObject.error) { + const errorResults = extractAllResults(apiObject.error.response.data); + if (errorResults.length > 0) { + // Mark all error results with error severity and merge with success results + return [...apiResults, ...errorResults.map((r) => ({ ...r, severity: "error" }))]; + } + + // Fallback to getCippError if extraction didn't work + const processedError = getCippError(apiObject.error); + if (typeof processedError === "string") { + return [ + ...apiResults, + { text: processedError, copyField: processedError, severity: "error" }, + ]; + } + } + return apiResults; - }, [correctResultObj]); + }, [correctResultObj, apiObject.isError, apiObject.error]); useEffect(() => { setErrorVisible(!!apiObject.isError); @@ -250,31 +269,8 @@ export const CippApiResults = (props) => { )} - {/* Error alert */} - - {apiObject.isError && ( - setErrorVisible(false)} - > - - - } - > - {getCippError(apiObject.error)} - - )} - - {/* Individual result alerts */} - {apiObject.isSuccess && !errorsOnly && hasVisibleResults && ( + {hasVisibleResults && ( <> {finalResults.map((resultObj) => ( diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 3c61b3cf6153..4532c6a035bb 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -5,6 +5,7 @@ import { createFilterOptions, TextField, IconButton, + Tooltip, } from "@mui/material"; import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useSettings } from "../../hooks/use-settings"; @@ -89,6 +90,7 @@ export const CippAutoComplete = (props) => { const [offCanvasVisible, setOffCanvasVisible] = useState(false); const [fullObject, setFullObject] = useState(null); const [internalValue, setInternalValue] = useState(null); // Track selected value internally + const [open, setOpen] = useState(false); // Control popover open state // Sync internalValue when external value or defaultValue prop changes (e.g., when editing a form) useEffect(() => { @@ -295,6 +297,15 @@ export const CippAutoComplete = (props) => { setOpen(true)} + onClose={(event, reason) => { + // Keep open if Tab was used in multiple mode + if (reason === "selectOption" && multiple && event?.type === "click") { + return; + } + setOpen(false); + }} disabled={disabled || actionGetRequest.isFetching || isFetching} popupIcon={ actionGetRequest.isFetching || isFetching ? ( @@ -425,6 +436,17 @@ export const CippAutoComplete = (props) => { event.preventDefault(); // Trigger a click on the highlighted option highlightedOption.click(); + + // In multiple mode, keep the popover open and refocus + if (multiple) { + setTimeout(() => { + setOpen(true); + const input = autocompleteRef.current?.querySelector("input"); + if (input) { + input.focus(); + } + }, 50); + } } } }} @@ -439,79 +461,83 @@ export const CippAutoComplete = (props) => { {...other} /> {api?.url && api?.showRefresh && ( - { - actionGetRequest.refetch(); - }} - > - - + + { + actionGetRequest.refetch(); + }} + > + + + )} {api?.templateView && ( - { - // Use internalValue if value prop is not available - const currentValue = value || internalValue; - - // Get the full object from the selected value - if (multiple) { - // For multiple selection, get all full objects - const fullObjects = currentValue - .map((v) => { - const valueToFind = v?.value || v; - const found = usedOptions.find((opt) => opt.value === valueToFind); - let rawData = found?.rawData; - - // If property is specified, extract and parse JSON from that property - if (rawData && api?.templateView?.property) { - try { - const propertyValue = rawData[api.templateView.property]; - if (typeof propertyValue === "string") { - rawData = JSON.parse(propertyValue); - } else { - rawData = propertyValue; + + { + // Use internalValue if value prop is not available + const currentValue = value || internalValue; + + // Get the full object from the selected value + if (multiple) { + // For multiple selection, get all full objects + const fullObjects = currentValue + .map((v) => { + const valueToFind = v?.value || v; + const found = usedOptions.find((opt) => opt.value === valueToFind); + let rawData = found?.rawData; + + // If property is specified, extract and parse JSON from that property + if (rawData && api?.templateView?.property) { + try { + const propertyValue = rawData[api.templateView.property]; + if (typeof propertyValue === "string") { + rawData = JSON.parse(propertyValue); + } else { + rawData = propertyValue; + } + } catch (e) { + console.error("Failed to parse JSON from property:", e); + // Keep original rawData if parsing fails } - } catch (e) { - console.error("Failed to parse JSON from property:", e); - // Keep original rawData if parsing fails } - } - return rawData; - }) - .filter(Boolean); - setFullObject(fullObjects); - } else { - // For single selection, get the full object - const valueToFind = currentValue?.value || currentValue; - const selectedOption = usedOptions.find((opt) => opt.value === valueToFind); - let rawData = selectedOption?.rawData || null; - - // If property is specified, extract and parse JSON from that property - if (rawData && api?.templateView?.property) { - try { - const propertyValue = rawData[api.templateView.property]; - if (typeof propertyValue === "string") { - rawData = JSON.parse(propertyValue); - } else { - rawData = propertyValue; + return rawData; + }) + .filter(Boolean); + setFullObject(fullObjects); + } else { + // For single selection, get the full object + const valueToFind = currentValue?.value || currentValue; + const selectedOption = usedOptions.find((opt) => opt.value === valueToFind); + let rawData = selectedOption?.rawData || null; + + // If property is specified, extract and parse JSON from that property + if (rawData && api?.templateView?.property) { + try { + const propertyValue = rawData[api.templateView.property]; + if (typeof propertyValue === "string") { + rawData = JSON.parse(propertyValue); + } else { + rawData = propertyValue; + } + } catch (e) { + console.error("Failed to parse JSON from property:", e); + // Keep original rawData if parsing fails } - } catch (e) { - console.error("Failed to parse JSON from property:", e); - // Keep original rawData if parsing fails } - } - setFullObject(rawData); - } - setOffCanvasVisible(true); - }} - title={api?.templateView.title || "View details"} - > - - + setFullObject(rawData); + } + setOffCanvasVisible(true); + }} + title={api?.templateView.title || "View details"} + > + + + )} )} diff --git a/src/components/CippComponents/CippBreadcrumbNav.jsx b/src/components/CippComponents/CippBreadcrumbNav.jsx new file mode 100644 index 000000000000..9a5ad7c807cd --- /dev/null +++ b/src/components/CippComponents/CippBreadcrumbNav.jsx @@ -0,0 +1,584 @@ +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/router"; +import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip } from "@mui/material"; +import { NavigateNext, History, AccountTree } from "@mui/icons-material"; +import { nativeMenuItems } from "../../layouts/config"; +import { useSettings } from "../../hooks/use-settings"; + +const MAX_HISTORY_STORAGE = 20; // Maximum number of pages to keep in history +const MAX_BREADCRUMB_DISPLAY = 5; // Maximum number of breadcrumbs to display at once + +/** + * Load all tabOptions.json files dynamically + */ +async function loadTabOptions() { + const tabOptionPaths = [ + "/email/administration/exchange-retention", + "/cipp/custom-data", + "/cipp/super-admin", + "/tenant/standards/list-standards", + "/tenant/manage", + "/tenant/administration/applications", + "/tenant/administration/tenants", + "/tenant/administration/audit-logs", + "/identity/administration/users/user", + "/tenant/administration/securescore", + "/tenant/gdap-management", + "/tenant/gdap-management/relationships/relationship", + "/cipp/settings", + ]; + + const tabOptions = []; + + for (const basePath of tabOptionPaths) { + try { + const module = await import(`/src/pages${basePath}/tabOptions.json`); + const options = module.default || module; + + // Add each tab option with metadata + options.forEach((option) => { + tabOptions.push({ + title: option.label, + path: option.path, + type: "tab", + basePath: basePath, + }); + }); + } catch (error) { + // Silently skip if file doesn't exist or can't be loaded + } + } + + return tabOptions; +} + +export const CippBreadcrumbNav = () => { + const router = useRouter(); + const settings = useSettings(); + const [history, setHistory] = useState([]); + const [mode, setMode] = useState(settings.breadcrumbMode || "hierarchical"); + const [tabOptions, setTabOptions] = useState([]); + const lastRouteRef = useRef(null); + const titleCheckCountRef = useRef(0); + const titleCheckIntervalRef = useRef(null); + + // Load tab options on mount + useEffect(() => { + loadTabOptions().then(setTabOptions); + }, []); + + useEffect(() => { + // Only update when the route actually changes, not on every render + const currentRoute = router.asPath; + + // Skip if this is the same route as last time + if (lastRouteRef.current === currentRoute) { + return; + } + + lastRouteRef.current = currentRoute; + + // Clear any existing title check interval + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + + // Reset check counter + titleCheckCountRef.current = 0; + + // Function to check and update title + const checkTitle = () => { + titleCheckCountRef.current++; + + // Stop checking after 50 attempts (5 seconds) to prevent infinite intervals + if (titleCheckCountRef.current > 50) { + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + return; + } + + let pageTitle = document.title.replace(" - CIPP", "").trim(); + + // Remove tenant domain from title (e.g., "Groups - domain.onmicrosoft.com" -> "Groups") + // But only if it looks like a domain (contains a dot) + const parts = pageTitle.split(" - "); + if (parts.length > 1 && parts[parts.length - 1].includes(".")) { + pageTitle = parts.slice(0, -1).join(" - ").trim(); + } + + // Skip if title is empty, generic, or error page + if ( + !pageTitle || + pageTitle === "CIPP" || + pageTitle.toLowerCase().includes("error") || + pageTitle === "404" || + pageTitle === "500" + ) { + return; + } + + // Normalize URL for comparison (remove trailing slashes and query params) + const normalizeUrl = (url) => { + // Remove query params and trailing slashes for comparison + return url.split("?")[0].replace(/\/$/, "").toLowerCase(); + }; + + const currentPage = { + title: pageTitle, + path: router.pathname, + query: { ...router.query }, + fullUrl: router.asPath, + timestamp: Date.now(), + }; + + const normalizedCurrentUrl = normalizeUrl(currentPage.fullUrl); + + setHistory((prevHistory) => { + // Check if last entry has same title AND similar path (prevent duplicate with same content) + const lastEntry = prevHistory[prevHistory.length - 1]; + if (lastEntry) { + const sameTitle = lastEntry.title.trim() === currentPage.title.trim(); + const samePath = normalizeUrl(lastEntry.fullUrl) === normalizedCurrentUrl; + + if (sameTitle && samePath) { + // Exact duplicate - don't add, just stop checking + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + return prevHistory; + } + + if (samePath && !sameTitle) { + // Same URL but title changed - update the entry + const updated = [...prevHistory]; + updated[prevHistory.length - 1] = currentPage; + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + return updated; + } + } + + // Find if this URL exists anywhere EXCEPT the last position in history + const existingIndex = prevHistory.findIndex((entry, index) => { + // Skip the last entry since we already checked it above + if (index === prevHistory.length - 1) return false; + return normalizeUrl(entry.fullUrl) === normalizedCurrentUrl; + }); + + // URL not in history (except possibly as last entry which we handled) - add as new entry + if (existingIndex === -1) { + const newHistory = [...prevHistory, currentPage]; + + // Keep only the last MAX_HISTORY_STORAGE pages + const trimmedHistory = + newHistory.length > MAX_HISTORY_STORAGE + ? newHistory.slice(-MAX_HISTORY_STORAGE) + : newHistory; + + // Don't stop checking yet - title might still be loading + return trimmedHistory; + } + + // URL exists in history but not as last entry - user navigated back + // Truncate history after this point and update the entry + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + const updated = prevHistory.slice(0, existingIndex + 1); + updated[existingIndex] = currentPage; + return updated; + }); + }; + + // Start checking for title updates + titleCheckIntervalRef.current = setInterval(checkTitle, 100); + + return () => { + if (titleCheckIntervalRef.current) { + clearInterval(titleCheckIntervalRef.current); + titleCheckIntervalRef.current = null; + } + }; + }, [router.asPath, router.pathname, router.query]); + + const handleBreadcrumbClick = (index) => { + const page = history[index]; + if (page) { + router.push({ + pathname: page.path, + query: page.query, + }); + } + }; + + // State to track current page title for hierarchical mode + const [currentPageTitle, setCurrentPageTitle] = useState(null); + const hierarchicalTitleCheckRef = useRef(null); + const hierarchicalCheckCountRef = useRef(0); + + // Watch for title changes to update hierarchical breadcrumbs + useEffect(() => { + if (mode === "hierarchical") { + // Clear any existing interval + if (hierarchicalTitleCheckRef.current) { + clearInterval(hierarchicalTitleCheckRef.current); + hierarchicalTitleCheckRef.current = null; + } + + // Reset counter + hierarchicalCheckCountRef.current = 0; + + const updateTitle = () => { + hierarchicalCheckCountRef.current++; + + // Stop after 20 attempts (10 seconds) to prevent infinite checking + if (hierarchicalCheckCountRef.current > 20) { + if (hierarchicalTitleCheckRef.current) { + clearInterval(hierarchicalTitleCheckRef.current); + hierarchicalTitleCheckRef.current = null; + } + return; + } + + const pageTitle = document.title.replace(" - CIPP", "").trim(); + const parts = pageTitle.split(" - "); + const cleanTitle = + parts.length > 1 && parts[parts.length - 1].includes(".") + ? parts.slice(0, -1).join(" - ").trim() + : pageTitle; + + if (cleanTitle && cleanTitle !== "CIPP" && !cleanTitle.toLowerCase().includes("loading")) { + setCurrentPageTitle(cleanTitle); + // Stop checking once we have a valid title + if (hierarchicalTitleCheckRef.current) { + clearInterval(hierarchicalTitleCheckRef.current); + hierarchicalTitleCheckRef.current = null; + } + } + }; + + // Initial update + updateTitle(); + + // Only start interval if we don't have a valid title yet + if (!currentPageTitle || currentPageTitle.toLowerCase().includes("loading")) { + hierarchicalTitleCheckRef.current = setInterval(updateTitle, 500); + } + + return () => { + if (hierarchicalTitleCheckRef.current) { + clearInterval(hierarchicalTitleCheckRef.current); + hierarchicalTitleCheckRef.current = null; + } + }; + } + }, [mode, router.pathname]); + + // Build hierarchical breadcrumbs from config.js navigation structure + const buildHierarchicalBreadcrumbs = () => { + const currentPath = router.pathname; + + // Helper to check if paths match (handles dynamic routes) + const pathsMatch = (menuPath, currentPath) => { + if (!menuPath) return false; + + // Exact match + if (menuPath === currentPath) return true; + + // Check if current path starts with menu path (for nested routes) + // e.g., menu: "/identity/administration/users" matches "/identity/administration/users/edit" + if (currentPath.startsWith(menuPath + "/")) return true; + + return false; + }; + + const findPathInMenu = (items, path = []) => { + for (const item of items) { + const currentBreadcrumb = [...path]; + + // Add current item to path if it has a title + // Include all items (headers, groups, and pages) to show full hierarchy + if (item.title) { + currentBreadcrumb.push({ + title: item.title, + path: item.path, + type: item.type, + query: {}, // Menu items don't have query params by default + }); + } + + // Check if this item matches the current path + if (item.path && pathsMatch(item.path, currentPath)) { + // If this is the current page, include current query params + if (item.path === currentPath) { + const lastItem = currentBreadcrumb[currentBreadcrumb.length - 1]; + if (lastItem) { + lastItem.query = { ...router.query }; + } + } + return currentBreadcrumb; + } + + // Recursively search children + if (item.items && item.items.length > 0) { + const result = findPathInMenu(item.items, currentBreadcrumb); + if (result.length > 0) { + return result; + } + } + } + return []; + }; + + let result = findPathInMenu(nativeMenuItems); + + // If not found in main menu, check if it's a tab page + if (result.length === 0 && tabOptions.length > 0) { + const normalizedCurrentPath = currentPath.replace(/\/$/, ""); + + // Find matching tab option + const matchingTab = tabOptions.find((tab) => { + const normalizedTabPath = tab.path.replace(/\/$/, ""); + return normalizedTabPath === normalizedCurrentPath; + }); + + if (matchingTab) { + // Find the base page in the menu and build full path to it + const normalizedBasePath = matchingTab.basePath?.replace(/\/$/, ""); + + // Recursively find the base page and build breadcrumb path + const findBasePageWithPath = (items, path = []) => { + for (const item of items) { + const currentBreadcrumb = [...path]; + + // Add current item to path if it has a title + if (item.title) { + currentBreadcrumb.push({ + title: item.title, + path: item.path, + type: item.type, + query: {}, // Menu items don't have query params by default + }); + } + + // Check if this item matches the base path + if (item.path) { + const normalizedItemPath = item.path.replace(/\/$/, ""); + if ( + normalizedItemPath === normalizedBasePath || + normalizedItemPath.startsWith(normalizedBasePath) + ) { + return currentBreadcrumb; + } + } + + // Recursively search children + if (item.items && item.items.length > 0) { + const found = findBasePageWithPath(item.items, currentBreadcrumb); + if (found.length > 0) { + return found; + } + } + } + return []; + }; + + const basePagePath = findBasePageWithPath(nativeMenuItems); + + if (basePagePath.length > 0) { + result = basePagePath; + + // Add the tab as the final breadcrumb with current query params + result.push({ + title: matchingTab.title, + path: matchingTab.path, + type: "tab", + query: { ...router.query }, // Include current query params for tab page + }); + } + } + } + + // Check if we're on a nested page under a menu item (e.g., edit page) + if (result.length > 0) { + const lastItem = result[result.length - 1]; + if (lastItem.path && lastItem.path !== currentPath && currentPath.startsWith(lastItem.path)) { + // Use the tracked page title if available, otherwise fall back to document.title + const tabTitle = currentPageTitle || document.title.replace(" - CIPP", "").trim(); + + // Add tab as an additional breadcrumb item + if ( + tabTitle && + tabTitle !== lastItem.title && + !tabTitle.toLowerCase().includes("loading") + ) { + result.push({ + title: tabTitle, + path: currentPath, + type: "tab", + query: { ...router.query }, // Include current query params + }); + } + } + } + + return result; + }; + + // Handle click for hierarchical breadcrumbs + const handleHierarchicalClick = (path, query) => { + if (path) { + if (query && Object.keys(query).length > 0) { + router.push({ + pathname: path, + query: query, + }); + } else { + router.push(path); + } + } + }; + + // Toggle between modes + const toggleMode = () => { + setMode((prevMode) => { + const newMode = prevMode === "hierarchical" ? "history" : "hierarchical"; + settings.handleUpdate({ breadcrumbMode: newMode }); + return newMode; + }); + }; + + // Render based on mode + if (mode === "hierarchical") { + const breadcrumbs = buildHierarchicalBreadcrumbs(); + + // Don't show if no breadcrumbs found + if (breadcrumbs.length === 0) { + return null; + } + + return ( + + + + + + + } + aria-label="page hierarchy" + sx={{ fontSize: "0.875rem", flexGrow: 1 }} + > + {breadcrumbs.map((crumb, index) => { + const isLast = index === breadcrumbs.length - 1; + + // Items without paths (headers/groups) - show as text + if (!crumb.path) { + return ( + + {crumb.title} + + ); + } + + // All items with paths are clickable, including the last one + return ( + handleHierarchicalClick(crumb.path, crumb.query)} + sx={{ + textDecoration: "none", + color: isLast ? "text.primary" : "text.secondary", + fontWeight: isLast ? 500 : 400, + "&:hover": { + textDecoration: "underline", + color: "primary.main", + }, + }} + > + {crumb.title} + + ); + })} + + + ); + } + + // Default mode: history-based breadcrumbs + // Don't show breadcrumbs if we have no history + if (history.length === 0) { + return null; + } + + // Show only the last MAX_BREADCRUMB_DISPLAY items + const visibleHistory = history.slice(-MAX_BREADCRUMB_DISPLAY); + + return ( + + + + + + + } + aria-label="navigation history" + sx={{ fontSize: "0.875rem", flexGrow: 1 }} + > + {visibleHistory.map((page, index) => { + const isLast = index === visibleHistory.length - 1; + // Calculate the actual index in the full history + const actualIndex = history.length - visibleHistory.length + index; + + if (isLast) { + return ( + + {page.title} + + ); + } + + return ( + handleBreadcrumbClick(actualIndex)} + sx={{ + textDecoration: "none", + color: "text.secondary", + "&:hover": { + textDecoration: "underline", + color: "primary.main", + }, + }} + > + {page.title} + + ); + })} + + + ); +}; diff --git a/src/components/CippComponents/CippCentralSearch.jsx b/src/components/CippComponents/CippCentralSearch.jsx index ee1c80e2b426..11e4b1e67a62 100644 --- a/src/components/CippComponents/CippCentralSearch.jsx +++ b/src/components/CippComponents/CippCentralSearch.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { Button, Dialog, @@ -15,6 +15,7 @@ import { import { Grid } from "@mui/system"; import { useRouter } from "next/router"; import { nativeMenuItems } from "/src/layouts/config"; +import { usePermissions } from "/src/hooks/use-permissions"; /** * Recursively collects only leaf items (those without sub-items). @@ -37,12 +38,208 @@ function getLeafItems(items = []) { return result; } +/** + * Load all tabOptions.json files dynamically + */ +async function loadTabOptions() { + const tabOptionPaths = [ + "/email/administration/exchange-retention", + "/cipp/custom-data", + "/cipp/super-admin", + "/tenant/standards/list-standards", + "/tenant/manage", + "/tenant/administration/applications", + "/tenant/administration/tenants", + "/tenant/administration/audit-logs", + "/identity/administration/users/user", + "/tenant/administration/securescore", + "/tenant/gdap-management", + "/tenant/gdap-management/relationships/relationship", + "/cipp/settings", + ]; + + const tabOptions = []; + + for (const basePath of tabOptionPaths) { + try { + const module = await import(`/src/pages${basePath}/tabOptions.json`); + const options = module.default || module; + + // Add each tab option with metadata + options.forEach((option) => { + tabOptions.push({ + title: option.label, + path: option.path, + type: "tab", + basePath: basePath, + }); + }); + } catch (error) { + // Silently skip if file doesn't exist or can't be loaded + console.debug(`Could not load tabOptions for ${basePath}:`, error); + } + } + + return tabOptions; +} + +/** + * Filter menu items based on user permissions and roles + */ +function filterItemsByPermissionsAndRoles(items, userPermissions, userRoles) { + return items.filter((item) => { + // Check roles if specified + if (item.roles && item.roles.length > 0) { + const hasRole = item.roles.some((requiredRole) => userRoles.includes(requiredRole)); + if (!hasRole) { + return false; + } + } + + // Check permissions with pattern matching support + if (item.permissions && item.permissions.length > 0) { + const hasPermission = userPermissions?.some((userPerm) => { + return item.permissions.some((requiredPerm) => { + // Exact match + if (userPerm === requiredPerm) { + return true; + } + + // Pattern matching - check if required permission contains wildcards + if (requiredPerm.includes("*")) { + // Convert wildcard pattern to regex + const regexPattern = requiredPerm + .replace(/\\/g, "\\\\") // Escape backslashes + .replace(/\./g, "\\.") // Escape dots + .replace(/\*/g, ".*"); // Convert * to .* + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(userPerm); + } + + return false; + }); + }); + if (!hasPermission) { + return false; + } + } + + return true; + }); +} + export const CippCentralSearch = ({ handleClose, open }) => { const router = useRouter(); const [searchValue, setSearchValue] = useState(""); + const { userPermissions, userRoles } = usePermissions(); + const [tabOptions, setTabOptions] = useState([]); + + // Load tab options on mount + useEffect(() => { + loadTabOptions().then(setTabOptions); + }, []); + + // Flatten and filter the menu items based on user permissions + const flattenedMenuItems = useMemo(() => { + const allLeafItems = getLeafItems(nativeMenuItems); + + // Helper to build full breadcrumb path + const buildBreadcrumbPath = (items, targetPath) => { + const searchRecursive = (items, currentPath = []) => { + for (const item of items) { + // Skip Dashboard root + const shouldAddToPath = item.title !== "Dashboard" || item.path !== "/"; + const newPath = shouldAddToPath ? [...currentPath, item.title] : currentPath; + + // Check if this item itself matches + if (item.path) { + const normalizedItemPath = item.path.replace(/\/$/, ""); + const normalizedTargetPath = targetPath.replace(/\/$/, ""); + + // Check if this item's path starts with target (item is under target path) + if (normalizedItemPath !== "/" && normalizedItemPath.startsWith(normalizedTargetPath)) { + // Return the full path + return newPath; + } + } + + // Check if this item's children match (for container items without paths) + if (item.items && item.items.length > 0) { + const childResult = searchRecursive(item.items, newPath); + if (childResult.length > 0) { + return childResult; + } + } + } + return []; + }; + + return searchRecursive(items); + }; + + const filteredMainMenu = filterItemsByPermissionsAndRoles( + allLeafItems, + userPermissions, + userRoles + ).map((item) => { + const rawBreadcrumbs = buildBreadcrumbPath(nativeMenuItems, item.path) || []; + // Remove the leaf item's own title to avoid duplicate when rendering + const trimmedBreadcrumbs = + rawBreadcrumbs.length > 0 && rawBreadcrumbs[rawBreadcrumbs.length - 1] === item.title + ? rawBreadcrumbs.slice(0, -1) + : rawBreadcrumbs; + return { + ...item, + breadcrumbs: trimmedBreadcrumbs, + }; + }); + + // Index leaf items by path for direct permission lookup + const leafItemIndex = allLeafItems.reduce((acc, item) => { + if (item.path) acc[item.path.replace(/\/$/, "")] = item; + return acc; + }, {}); - // Flatten the menu items once - const flattenedMenuItems = getLeafItems(nativeMenuItems); + // Filter tab options based on the actual page item's permissions + const filteredTabOptions = tabOptions + .map((tab) => { + const normalizedTabPath = tab.path.replace(/\/$/, ""); + const normalizedBasePath = tab.basePath?.replace(/\/$/, ""); + + // Try exact match first + let pageItem = leafItemIndex[normalizedTabPath]; + + // Fallback: find any menu item whose path starts with the basePath + if (!pageItem && normalizedBasePath) { + pageItem = allLeafItems.find((item) => { + const normalizedItemPath = item.path?.replace(/\/$/, ""); + return normalizedItemPath && normalizedItemPath.startsWith(normalizedBasePath); + }); + } + + if (!pageItem) return null; // No matching page definition + + // Permission/role check using the page item directly + const hasAccessToPage = + filterItemsByPermissionsAndRoles([pageItem], userPermissions, userRoles).length > 0; + if (!hasAccessToPage) return null; + + // Build breadcrumbs using the pageItem's path (which exists in menu tree) + const breadcrumbs = buildBreadcrumbPath(nativeMenuItems, pageItem.path) || []; + // Remove duplicate last crumb if equal to tab title (will be appended during render) + const trimmedBreadcrumbs = + breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1] === tab.title + ? breadcrumbs.slice(0, -1) + : breadcrumbs; + return { + ...tab, + breadcrumbs: trimmedBreadcrumbs, + }; + }) + .filter(Boolean); + + return [...filteredMainMenu, ...filteredTabOptions]; + }, [userPermissions, userRoles, tabOptions]); const handleChange = (event) => { setSearchValue(event.target.value); @@ -55,13 +252,16 @@ export const CippCentralSearch = ({ handleClose, open }) => { } }; - // Filter leaf items by matching title or path + // Filter leaf items by matching title, path, or breadcrumbs const normalizedSearch = searchValue.trim().toLowerCase(); const filteredItems = flattenedMenuItems.filter((leaf) => { const inTitle = leaf.title?.toLowerCase().includes(normalizedSearch); const inPath = leaf.path?.toLowerCase().includes(normalizedSearch); + const inBreadcrumbs = leaf.breadcrumbs?.some((crumb) => + crumb?.toLowerCase().includes(normalizedSearch) + ); // If there's no search value, show no results (you could change this logic) - return normalizedSearch ? inTitle || inPath : false; + return normalizedSearch ? inTitle || inPath || inBreadcrumbs : false; }); // Helper to bold‐highlight the matched text @@ -79,6 +279,14 @@ export const CippCentralSearch = ({ handleClose, open }) => { ); }; + // Helper to get item type label + const getItemTypeLabel = (item) => { + if (item.type === "tab") { + return "Tab"; + } + return "Page"; + }; + // Click handler: shallow navigate with Next.js const handleCardClick = (path) => { router.push(path, undefined, { shallow: true }); @@ -118,7 +326,34 @@ export const CippCentralSearch = ({ handleClose, open }) => { aria-label={`Navigate to ${item.title}`} > - {highlightMatch(item.title)} + + {highlightMatch(item.title)} + + {getItemTypeLabel(item)} + + + {item.breadcrumbs && item.breadcrumbs.length > 0 && ( + + {item.breadcrumbs.map((crumb, idx) => ( + + {highlightMatch(crumb)} + {idx < item.breadcrumbs.length - 1 && " > "} + + ))} + {" > "} + {highlightMatch(item.title)} + + )} Path: {highlightMatch(item.path)} diff --git a/src/components/CippComponents/CippExchangeActions.jsx b/src/components/CippComponents/CippExchangeActions.jsx index 1a4be9744d55..e88171a3f20f 100644 --- a/src/components/CippComponents/CippExchangeActions.jsx +++ b/src/components/CippComponents/CippExchangeActions.jsx @@ -301,12 +301,14 @@ export const CippExchangeActions = () => { label: "Set Copy Sent Items for Delegated Mailboxes", type: "POST", icon: , + condition: (row) => + row.recipientTypeDetails === "UserMailbox" || row.recipientTypeDetails === "SharedMailbox", url: "/api/ExecCopyForSent", data: { ID: "UPN" }, fields: [ { type: "radio", - name: "MessageCopyForSentAsEnabled", + name: "messageCopyState", label: "Copy Sent Items", options: [ { label: "Enabled", value: true }, diff --git a/src/components/CippComponents/CippTablePage.jsx b/src/components/CippComponents/CippTablePage.jsx index 5ab4223b933d..60b6fc127b19 100644 --- a/src/components/CippComponents/CippTablePage.jsx +++ b/src/components/CippComponents/CippTablePage.jsx @@ -24,7 +24,7 @@ export const CippTablePage = (props) => { tableFilter, tenantInTitle = true, filters, - sx = { flexGrow: 1, py: 4 }, + sx = { flexGrow: 1, pb: 4 }, ...other } = props; const tenant = useSettings().currentTenant; @@ -65,10 +65,12 @@ export const CippTablePage = (props) => { offCanvas={offCanvas} filters={tableFilters} initialState={{ - columnFilters: filters ? filters.map(filter => ({ - id: filter.id || filter.columnId, - value: filter.value - })) : [] + columnFilters: filters + ? filters.map((filter) => ({ + id: filter.id || filter.columnId, + value: filter.value, + })) + : [], }} {...other} /> diff --git a/src/components/CippComponents/CippTransportRuleDrawer.jsx b/src/components/CippComponents/CippTransportRuleDrawer.jsx index 8cac4e3fd390..b3beea3d0b1c 100644 --- a/src/components/CippComponents/CippTransportRuleDrawer.jsx +++ b/src/components/CippComponents/CippTransportRuleDrawer.jsx @@ -104,8 +104,10 @@ export const CippTransportRuleDrawer = ({ const conditionFieldMap = { From: "The sender is...", FromScope: "The sender is located...", + FromMemberOf: "The sender is a member of...", SentTo: "The recipient is...", SentToScope: "The recipient is located...", + SentToMemberOf: "The recipient is a member of...", SubjectContainsWords: "Subject contains words...", SubjectMatchesPatterns: "Subject matches patterns...", SubjectOrBodyContainsWords: "Subject or body contains words...", @@ -122,8 +124,19 @@ export const CippTransportRuleDrawer = ({ MessageTypeMatches: "Message type is...", SenderDomainIs: "Sender domain is...", RecipientDomainIs: "Recipient domain is...", + SenderIpRanges: "Sender IP address belongs to any of these ranges...", HeaderContainsWords: "Message header contains words...", HeaderMatchesPatterns: "Message header matches patterns...", + AnyOfToHeader: "Any To header contains...", + AnyOfToHeaderMemberOf: "Any To header is a member of...", + AnyOfCcHeader: "Any Cc header contains...", + AnyOfCcHeaderMemberOf: "Any Cc header is a member of...", + AnyOfToCcHeader: "Any To or Cc header contains...", + AnyOfToCcHeaderMemberOf: "Any To or Cc header is a member of...", + RecipientAddressContainsWords: "Recipient address contains words...", + RecipientAddressMatchesPatterns: "Recipient address matches patterns...", + AnyOfRecipientAddressContainsWords: "Any recipient address contains words...", + AnyOfRecipientAddressMatchesPatterns: "Any recipient address matches patterns...", }; const actionFieldMap = { @@ -232,6 +245,26 @@ export const CippTransportRuleDrawer = ({ formData[field] = rule[field] !== null ? { value: rule[field].toString(), label: rule[field].toString() } : undefined; + } else if (field === "SenderIpRanges") { + // Transform array of IP strings to autocomplete format + if (Array.isArray(rule[field])) { + formData[field] = rule[field].map(ip => ({ value: ip, label: ip })); + } else { + formData[field] = rule[field]; + } + } else if ( + // Fields that use creatable autocomplete with API (users/groups) + field === "From" || field === "SentTo" || + field === "AnyOfToHeader" || field === "AnyOfCcHeader" || field === "AnyOfToCcHeader" || + field === "FromMemberOf" || field === "SentToMemberOf" || + field === "AnyOfToHeaderMemberOf" || field === "AnyOfCcHeaderMemberOf" || field === "AnyOfToCcHeaderMemberOf" + ) { + // Transform array of email/UPN strings to autocomplete format + if (Array.isArray(rule[field])) { + formData[field] = rule[field].map(item => ({ value: item, label: item })); + } else { + formData[field] = rule[field]; + } } else { formData[field] = rule[field]; } @@ -287,6 +320,26 @@ export const CippTransportRuleDrawer = ({ formData[exceptionField] = rule[exceptionField] !== null ? { value: rule[exceptionField].toString(), label: rule[exceptionField].toString() } : undefined; + } else if (field === "SenderIpRanges") { + // Transform array of IP strings to autocomplete format + if (Array.isArray(rule[exceptionField])) { + formData[exceptionField] = rule[exceptionField].map(ip => ({ value: ip, label: ip })); + } else { + formData[exceptionField] = rule[exceptionField]; + } + } else if ( + // Fields that use creatable autocomplete with API (users/groups) + field === "From" || field === "SentTo" || + field === "AnyOfToHeader" || field === "AnyOfCcHeader" || field === "AnyOfToCcHeader" || + field === "FromMemberOf" || field === "SentToMemberOf" || + field === "AnyOfToHeaderMemberOf" || field === "AnyOfCcHeaderMemberOf" || field === "AnyOfToCcHeaderMemberOf" + ) { + // Transform array of email/UPN strings to autocomplete format + if (Array.isArray(rule[exceptionField])) { + formData[exceptionField] = rule[exceptionField].map(item => ({ value: item, label: item })); + } else { + formData[exceptionField] = rule[exceptionField]; + } } else { formData[exceptionField] = rule[exceptionField]; } @@ -345,9 +398,22 @@ export const CippTransportRuleDrawer = ({ const conditionValue = condition.value || condition; if (values[conditionValue] !== undefined) { const fieldValue = values[conditionValue]; - if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + + // Handle single object with value property + if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue) && fieldValue.value !== undefined) { apiData[conditionValue] = fieldValue.value; - } else { + } + // Handle array of objects with value property (for creatable autocomplete fields) + else if (Array.isArray(fieldValue)) { + apiData[conditionValue] = fieldValue.map(item => { + if (item && typeof item === 'object' && item.value !== undefined) { + return item.value; + } + return item; + }); + } + // Handle plain values + else { apiData[conditionValue] = fieldValue; } } @@ -389,9 +455,22 @@ export const CippTransportRuleDrawer = ({ } } else if (values[actionValue] !== undefined) { const fieldValue = values[actionValue]; - if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + + // Handle single object with value property + if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue) && fieldValue.value !== undefined) { apiData[actionValue] = fieldValue.value; - } else { + } + // Handle array of objects with value property (for creatable autocomplete fields) + else if (Array.isArray(fieldValue)) { + apiData[actionValue] = fieldValue.map(item => { + if (item && typeof item === 'object' && item.value !== undefined) { + return item.value; + } + return item; + }); + } + // Handle plain values + else { apiData[actionValue] = fieldValue; } } @@ -402,9 +481,22 @@ export const CippTransportRuleDrawer = ({ const exceptionValue = exception.value || exception; if (values[exceptionValue] !== undefined) { const fieldValue = values[exceptionValue]; - if (fieldValue && typeof fieldValue === 'object' && fieldValue.value !== undefined) { + + // Handle single object with value property + if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue) && fieldValue.value !== undefined) { apiData[exceptionValue] = fieldValue.value; - } else { + } + // Handle array of objects with value property (for creatable autocomplete fields) + else if (Array.isArray(fieldValue)) { + apiData[exceptionValue] = fieldValue.map(item => { + if (item && typeof item === 'object' && item.value !== undefined) { + return item.value; + } + return item; + }); + } + // Handle plain values + else { apiData[exceptionValue] = fieldValue; } } @@ -535,8 +627,10 @@ export const CippTransportRuleDrawer = ({ const conditionOptions = [ { value: "From", label: "The sender is..." }, { value: "FromScope", label: "The sender is located..." }, + { value: "FromMemberOf", label: "The sender is a member of..." }, { value: "SentTo", label: "The recipient is..." }, { value: "SentToScope", label: "The recipient is located..." }, + { value: "SentToMemberOf", label: "The recipient is a member of..." }, { value: "SubjectContainsWords", label: "Subject contains words..." }, { value: "SubjectMatchesPatterns", label: "Subject matches patterns..." }, { value: "SubjectOrBodyContainsWords", label: "Subject or body contains words..." }, @@ -553,8 +647,19 @@ export const CippTransportRuleDrawer = ({ { value: "MessageTypeMatches", label: "Message type is..." }, { value: "SenderDomainIs", label: "Sender domain is..." }, { value: "RecipientDomainIs", label: "Recipient domain is..." }, + { value: "SenderIpRanges", label: "Sender IP address belongs to any of these ranges..." }, { value: "HeaderContainsWords", label: "Message header contains words..." }, { value: "HeaderMatchesPatterns", label: "Message header matches patterns..." }, + { value: "AnyOfToHeader", label: "Any To header contains..." }, + { value: "AnyOfToHeaderMemberOf", label: "Any To header is a member of..." }, + { value: "AnyOfCcHeader", label: "Any Cc header contains..." }, + { value: "AnyOfCcHeaderMemberOf", label: "Any Cc header is a member of..." }, + { value: "AnyOfToCcHeader", label: "Any To or Cc header contains..." }, + { value: "AnyOfToCcHeaderMemberOf", label: "Any To or Cc header is a member of..." }, + { value: "RecipientAddressContainsWords", label: "Recipient address contains words..." }, + { value: "RecipientAddressMatchesPatterns", label: "Recipient address matches patterns..." }, + { value: "AnyOfRecipientAddressContainsWords", label: "Any recipient address contains words..." }, + { value: "AnyOfRecipientAddressMatchesPatterns", label: "Any recipient address matches patterns..." }, ]; // Action options @@ -585,6 +690,9 @@ export const CippTransportRuleDrawer = ({ switch (conditionValue) { case "From": case "SentTo": + case "AnyOfToHeader": + case "AnyOfCcHeader": + case "AnyOfToCcHeader": return ( ); + case "FromMemberOf": + case "SentToMemberOf": + case "AnyOfToHeaderMemberOf": + case "AnyOfCcHeaderMemberOf": + case "AnyOfToCcHeaderMemberOf": + return ( + + `${option.displayName}${option.mail ? ` (${option.mail})` : ''}`, + valueField: "mail", + dataKey: "Results", + }} + /> + + ); + case "FromScope": case "SentToScope": return ( @@ -718,6 +857,22 @@ export const CippTransportRuleDrawer = ({ ); + case "SenderIpRanges": + return ( + + + + ); + case "HeaderContainsWords": case "HeaderMatchesPatterns": return ( diff --git a/src/components/CippComponents/ScheduledTaskDetails.jsx b/src/components/CippComponents/ScheduledTaskDetails.jsx index 554c72261c4b..dd1255dbf7ec 100644 --- a/src/components/CippComponents/ScheduledTaskDetails.jsx +++ b/src/components/CippComponents/ScheduledTaskDetails.jsx @@ -22,6 +22,7 @@ import { CippDataTable } from "../CippTable/CippDataTable"; import { CippTimeAgo } from "/src/components/CippComponents/CippTimeAgo"; import { ActionsMenu } from "/src/components/actions-menu"; import { CippScheduledTaskActions } from "./CippScheduledTaskActions"; +import { CippApiLogsDrawer } from "./CippApiLogsDrawer"; const ScheduledTaskDetails = ({ data, showActions = true }) => { const [taskDetails, setTaskDetails] = useState(null); @@ -81,12 +82,18 @@ const ScheduledTaskDetails = ({ data, showActions = true }) => { return ( <> - + {taskDetailResults.isLoading ? : taskDetails?.Task?.Name} {showActions && ( - + + { - + {!hideTitle && ( - {!hideBackButton && ( -
- -
- )} -
diff --git a/src/components/CippFormPages/CippJSONView.jsx b/src/components/CippFormPages/CippJSONView.jsx index 5f3de20a24f7..0215385cbc89 100644 --- a/src/components/CippFormPages/CippJSONView.jsx +++ b/src/components/CippFormPages/CippJSONView.jsx @@ -20,8 +20,7 @@ import { getCippTranslation } from "../../utils/get-cipp-translation"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { CippCodeBlock } from "../CippComponents/CippCodeBlock"; import intuneCollection from "/src/data/intuneCollection.json"; -import { ApiPostCall } from "../../api/ApiCall"; -import { useSettings } from "../../hooks/use-settings"; +import { useGuidResolver } from "../../hooks/use-guid-resolver"; const cleanObject = (obj) => { if (Array.isArray(obj)) { @@ -42,30 +41,93 @@ const cleanObject = (obj) => { } }; -// Function to check if a string is a GUID -const isGuid = (str) => { - if (typeof str !== "string") return false; - const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - return guidRegex.test(str); -}; - -// Function to recursively scan an object for GUIDs -const findGuids = (obj, guidsSet = new Set()) => { - if (!obj) return guidsSet; +const renderListItems = (data, onItemClick, guidMapping = {}, isLoadingGuids = false, isGuid) => { + // Check if this data object is from a diff + const isDiffData = data?.__isDiffData === true; + + // Helper to try parsing JSON strings + const tryParseJson = (str) => { + if (typeof str !== "string") return null; + try { + return JSON.parse(str); + } catch { + return null; + } + }; - if (typeof obj === "string" && isGuid(obj)) { - guidsSet.add(obj); - } else if (Array.isArray(obj)) { - obj.forEach((item) => findGuids(item, guidsSet)); - } else if (typeof obj === "object") { - Object.values(obj).forEach((value) => findGuids(value, guidsSet)); - } + // Helper to get deep object differences + const getObjectDiff = (oldObj, newObj, path = "") => { + const changes = []; + const allKeys = new Set([...Object.keys(oldObj || {}), ...Object.keys(newObj || {})]); + + allKeys.forEach((key) => { + const currentPath = path ? `${path}.${key}` : key; + const oldVal = oldObj?.[key]; + const newVal = newObj?.[key]; + + if (oldVal === undefined && newVal !== undefined) { + changes.push({ path: currentPath, type: "added", newValue: newVal }); + } else if (oldVal !== undefined && newVal === undefined) { + changes.push({ path: currentPath, type: "removed", oldValue: oldVal }); + } else if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + if (typeof oldVal === "object" && typeof newVal === "object" && oldVal && newVal) { + changes.push(...getObjectDiff(oldVal, newVal, currentPath)); + } else { + changes.push({ path: currentPath, type: "modified", oldValue: oldVal, newValue: newVal }); + } + } + }); - return guidsSet; -}; + return changes; + }; -const renderListItems = (data, onItemClick, guidMapping = {}, isLoadingGuids = false) => { return Object.entries(data).map(([key, value]) => { + // Skip the diff marker key + if (key === "__isDiffData") { + return null; + } + + // Special handling for oldValue/newValue pairs + if (key === "oldValue" && data.newValue !== undefined) { + const oldObj = tryParseJson(value); + const newObj = tryParseJson(data.newValue); + + // If both are JSON objects, show detailed diff + if (oldObj && newObj) { + const diff = getObjectDiff(oldObj, newObj); + if (diff.length > 0) { + return ( + onItemClick({ changes: diff })}> + View {diff.length} change{diff.length > 1 ? "s" : ""} + + } + /> + ); + } + } else { + // For simple strings or non-JSON values, show old → new + return ( + + ); + } + } + + // Skip newValue if we already handled it with oldValue + if (key === "newValue" && data.oldValue !== undefined) { + return null; + } + if (Array.isArray(value)) { return ( ); } else { - return ( - - ); + // If this is diff data, show the value directly without formatting + const displayValue = isDiffData ? value : getCippFormatting(value, key); + + return ; } }); }; @@ -132,97 +191,14 @@ function CippJsonView({ object = { "No Data Selected": "No Data Selected" }, type, defaultOpen = false, + title = "Policy Details", }) { const [viewJson, setViewJson] = useState(false); const [accordionOpen, setAccordionOpen] = useState(defaultOpen); - const [drilldownData, setDrilldownData] = useState([]); - const [guidMapping, setGuidMapping] = useState({}); - const [notFoundGuids, setNotFoundGuids] = useState(new Set()); - const [isLoadingGuids, setIsLoadingGuids] = useState(false); - const [pendingGuids, setPendingGuids] = useState([]); - const [lastRequestTime, setLastRequestTime] = useState(0); - const tenantFilter = useSettings().currentTenant; - - // Setup API call for directory objects resolution - const directoryObjectsMutation = ApiPostCall({ - relatedQueryKeys: ["directoryObjects"], - onResult: (data) => { - if (data && Array.isArray(data.value)) { - const newMapping = {}; - - // Process the returned results - data.value.forEach((item) => { - if (item.id && (item.displayName || item.userPrincipalName || item.mail)) { - // Prefer displayName, fallback to UPN or mail if available - newMapping[item.id] = item.displayName || item.userPrincipalName || item.mail; - } - }); - - // Find GUIDs that were sent but not returned in the response - const processedGuids = new Set(pendingGuids); - const returnedGuids = new Set(data.value.map((item) => item.id)); - const notReturned = [...processedGuids].filter((guid) => !returnedGuids.has(guid)); - - // Add them to the notFoundGuids set - if (notReturned.length > 0) { - setNotFoundGuids((prev) => { - const newSet = new Set(prev); - notReturned.forEach((guid) => newSet.add(guid)); - return newSet; - }); - } - - setGuidMapping((prevMapping) => ({ ...prevMapping, ...newMapping })); - setPendingGuids([]); - setIsLoadingGuids(false); - } - }, - }); - - // Function to handle resolving GUIDs - used in both useEffect and handleItemClick - const resolveGuids = (objectToScan) => { - const guidsSet = findGuids(objectToScan); - - if (guidsSet.size === 0) return; - - const guidsArray = Array.from(guidsSet); - // Filter out GUIDs that are already resolved or known to not be resolvable - const notResolvedGuids = guidsArray.filter( - (guid) => !guidMapping[guid] && !notFoundGuids.has(guid) - ); - - if (notResolvedGuids.length === 0) return; + const [drilldownData, setDrilldownData] = useState([]); // Array of { data, title } - // Merge with any pending GUIDs to avoid duplicate requests - const allPendingGuids = [...new Set([...pendingGuids, ...notResolvedGuids])]; - setPendingGuids(allPendingGuids); - setIsLoadingGuids(true); - - // Implement throttling - only send a new request every 2 seconds - const now = Date.now(); - if (now - lastRequestTime < 2000) { - return; - } - - setLastRequestTime(now); - - // Only send a maximum of 1000 GUIDs per request - const batchSize = 1000; - const guidsToSend = allPendingGuids.slice(0, batchSize); - - if (guidsToSend.length > 0) { - directoryObjectsMutation.mutate({ - url: "/api/ListDirectoryObjects", - data: { - tenantFilter: tenantFilter, - ids: guidsToSend, - $select: "id,displayName,userPrincipalName,mail", - }, - }); - } else { - setIsLoadingGuids(false); - } - }; + // Use the GUID resolver hook + const { guidMapping, isLoadingGuids, resolveGuids, isGuid } = useGuidResolver(); const renderIntuneItems = (data) => { const items = []; @@ -441,53 +417,156 @@ function CippJsonView({ const filteredObj = Object.fromEntries( Object.entries(cleanedObj).filter(([key]) => !blacklist.includes(key)) ); - setDrilldownData([filteredObj]); + setDrilldownData([{ data: filteredObj, title: null }]); - // Using the centralized resolveGuids function to handle GUID resolution + // Using the resolveGuids function from the hook to handle GUID resolution resolveGuids(cleanedObj); - }, [object, tenantFilter]); - - // Effect to reprocess any pending GUIDs when the guidMapping changes or throttling window passes - useEffect(() => { - if (pendingGuids.length > 0 && !isLoadingGuids) { - const now = Date.now(); - if (now - lastRequestTime >= 2000) { - // Only send a maximum of 1000 GUIDs per request - const batchSize = 1000; - const guidsToSend = pendingGuids.slice(0, batchSize); - - setLastRequestTime(now); - setIsLoadingGuids(true); - - directoryObjectsMutation.mutate({ - url: "/api/ListDirectoryObjects", - data: { - tenantFilter: tenantFilter, - ids: guidsToSend, - $select: "id,displayName,userPrincipalName,mail", - }, - }); - } - } - }, [ - guidMapping, - notFoundGuids, - pendingGuids, - lastRequestTime, - isLoadingGuids, - directoryObjectsMutation, - tenantFilter, - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [object]); const toggleView = () => setViewJson(!viewJson); const handleItemClick = (itemData, level) => { const updatedData = drilldownData.slice(0, level + 1); - updatedData[level + 1] = itemData; + + // Helper to check if an array contains only simple key/value objects + const isArrayOfKeyValuePairs = (arr) => { + if (!Array.isArray(arr) || arr.length === 0) return false; + return arr.every((item) => { + if (typeof item !== "object" || item === null || Array.isArray(item)) return false; + // Check if all values are primitives (not nested objects/arrays) + return Object.values(item).every((val) => typeof val !== "object" || val === null); + }); + }; + + // Compress single-property objects and single-item arrays into the same pane + let dataToAdd = itemData; + const compressedKeys = []; + let wasCompressed = false; + + // Special handling for diff changes object + if (dataToAdd?.changes && Array.isArray(dataToAdd.changes)) { + const diffObject = {}; + const blacklistFields = ["createdDateTime", "modifiedDateTime", "id"]; + + dataToAdd.changes.forEach((change) => { + const label = change.path; + + // Skip blacklisted fields in nested paths + const pathParts = label.split("."); + const lastPart = pathParts[pathParts.length - 1]; + if (blacklistFields.includes(lastPart)) { + return; + } + + let hasValue = false; + let displayValue = ""; + + if (change.type === "added") { + if (change.newValue !== null && change.newValue !== undefined && change.newValue !== "") { + displayValue = `[ADDED] ${JSON.stringify(change.newValue)}`; + hasValue = true; + } + } else if (change.type === "removed") { + if (change.oldValue !== null && change.oldValue !== undefined && change.oldValue !== "") { + displayValue = `[REMOVED] ${JSON.stringify(change.oldValue)}`; + hasValue = true; + } + } else if (change.type === "modified") { + const oldHasValue = + change.oldValue !== null && change.oldValue !== undefined && change.oldValue !== ""; + const newHasValue = + change.newValue !== null && change.newValue !== undefined && change.newValue !== ""; + + // Only show if at least one side has a meaningful value (not both empty) + if (oldHasValue || newHasValue) { + // If both have values, show the change + if (oldHasValue && newHasValue) { + displayValue = `${JSON.stringify(change.oldValue)} → ${JSON.stringify( + change.newValue + )}`; + hasValue = true; + } + // If only new has value, treat as added + else if (newHasValue) { + displayValue = `[ADDED] ${JSON.stringify(change.newValue)}`; + hasValue = true; + } + // If only old has value, treat as removed + else if (oldHasValue) { + displayValue = `[REMOVED] ${JSON.stringify(change.oldValue)}`; + hasValue = true; + } + } + } + + if (hasValue) { + diffObject[label] = displayValue; + } + }); + // Mark this object as containing diff data + dataToAdd = { ...diffObject, __isDiffData: true }; + } + + // Check if this is an array of items with oldValue/newValue (modifiedProperties pattern) + const hasOldNewValues = (arr) => { + if (!Array.isArray(arr) || arr.length === 0) return false; + return arr.some((item) => item?.oldValue !== undefined || item?.newValue !== undefined); + }; + + // If the data is an array of key/value pairs, convert to a flat object + // But skip if it's an array with oldValue/newValue properties (let normal rendering handle it) + if (isArrayOfKeyValuePairs(dataToAdd) && !hasOldNewValues(dataToAdd)) { + const flatObject = {}; + dataToAdd.forEach((item) => { + const key = item.key || item.name || item.displayName; + const value = item.value || item.newValue || ""; + if (key) { + flatObject[key] = value; + } + }); + dataToAdd = flatObject; + } + + while (dataToAdd && typeof dataToAdd === "object") { + // Handle single-item arrays + if (Array.isArray(dataToAdd) && dataToAdd.length === 1) { + const singleItem = dataToAdd[0]; + if (singleItem && typeof singleItem === "object") { + compressedKeys.push("[0]"); + dataToAdd = singleItem; + wasCompressed = true; + continue; + } else { + break; + } + } + + // Handle single-property objects + if (!Array.isArray(dataToAdd) && Object.keys(dataToAdd).length === 1) { + const singleKey = Object.keys(dataToAdd)[0]; + const singleValue = dataToAdd[singleKey]; + + // Only compress if the value is also an object or single-item array + if (singleValue && typeof singleValue === "object") { + compressedKeys.push(singleKey); + dataToAdd = singleValue; + wasCompressed = true; + continue; + } + } + + break; + } + + // Create title from compressed keys if compression occurred + const title = wasCompressed ? compressedKeys.join(" > ") : null; + + updatedData[level + 1] = { data: dataToAdd, title }; setDrilldownData(updatedData); - // Use the centralized resolveGuids function to handle GUID resolution for drill-down data - resolveGuids(itemData); + // Use the resolveGuids function from the hook to handle GUID resolution for drill-down data + resolveGuids(dataToAdd); }; return ( @@ -502,7 +581,7 @@ function CippJsonView({ > - Policy Details + {title} {isLoadingGuids && ( @@ -520,8 +599,8 @@ function CippJsonView({ ) : ( {drilldownData - ?.filter((data) => data !== null && data !== undefined) - .map((data, index) => ( + ?.filter((item) => item !== null && item !== undefined) + .map((item, index) => ( + {item.title && ( + + {getCippTranslation(item.title)} + + )} {type !== "intune" && ( {renderListItems( - data, + item.data, (itemData) => handleItemClick(itemData, index), guidMapping, - isLoadingGuids + isLoadingGuids, + isGuid )} )} - {type === "intune" && {renderIntuneItems(data)}} + {type === "intune" && {renderIntuneItems(item.data)}} ))} diff --git a/src/components/CippTable/CippGraphExplorerFilter.js b/src/components/CippTable/CippGraphExplorerFilter.js index ad1315667b35..3167e5b7ca91 100644 --- a/src/components/CippTable/CippGraphExplorerFilter.js +++ b/src/components/CippTable/CippGraphExplorerFilter.js @@ -41,6 +41,7 @@ const CippGraphExplorerFilter = ({ mode: "onChange", defaultValues: { endpoint: "", + version: { label: "beta", value: "beta" }, $select: [], $filter: "", $expand: "", @@ -209,6 +210,21 @@ const CippGraphExplorerFilter = ({ ?.split(",") .map((item) => ({ label: item, value: item }))) : (selectedPresets.addedFields.params.$select = []); + + // Convert version string to autocomplete object format, default to beta if not present + if (selectedPresets.addedFields.params.version) { + const versionValue = + typeof selectedPresets.addedFields.params.version === "string" + ? selectedPresets.addedFields.params.version + : selectedPresets.addedFields.params.version.value; + selectedPresets.addedFields.params.version = { + label: versionValue, + value: versionValue, + }; + } else { + selectedPresets.addedFields.params.version = { label: "beta", value: "beta" }; + } + selectedPresets.addedFields.params.id = selectedPresets.value; setSelectedPreset(selectedPresets.value); selectedPresets.addedFields.params.name = selectedPresets.label; @@ -367,6 +383,11 @@ const CippGraphExplorerFilter = ({ if (newvals?.$select !== undefined && Array.isArray(newvals?.$select)) { newvals.$select = newvals?.$select.map((p) => p.value).join(","); } + if (newvals.version && newvals.version.value) { + newvals.version = newvals.version.value; + } else if (!newvals.version) { + newvals.version = "beta"; + } delete newvals["reportTemplate"]; delete newvals["tenantFilter"]; delete newvals["IsShared"]; @@ -432,6 +453,11 @@ const CippGraphExplorerFilter = ({ if (values.$select && Array.isArray(values.$select) && values.$select.length > 0) { values.$select = values?.$select?.map((item) => item.value)?.join(","); } + if (values.version && values.version.value) { + values.version = values.version.value; + } else if (!values.version) { + values.version = "beta"; + } if (values.ReverseTenantLookup === false) { delete values.ReverseTenantLookup; } @@ -602,6 +628,22 @@ const CippGraphExplorerFilter = ({ /> + + + + { sx={{ backgroundColor: "background.default", flexGrow: 1, - py: 4, + pb: 4, }} > - {backButton && ( - - )} diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.js index 35c87c90d658..bc2e177d73cc 100644 --- a/src/contexts/settings-context.js +++ b/src/contexts/settings-context.js @@ -80,6 +80,7 @@ const initialSettings = { }, persistFilters: false, lastUsedFilters: {}, + breadcrumbMode: "hierarchical", }; const initialState = { @@ -112,6 +113,12 @@ export const SettingsProvider = (props) => { ...restored, isInitialized: true, })); + } else { + // No stored settings found, initialize with defaults + setState((prevState) => ({ + ...prevState, + isInitialized: true, + })); } }, []); diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index 50b1b2e1e034..a449a934b1b4 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -55,25 +55,12 @@ export const HeaderedTabbedLayout = (props) => { -
- -
{ !mdDown && { flexGrow: 1, overflow: "auto", - height: "calc(100vh - 400px)", + height: "calc(100vh - 30px)", } } > diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index 9594443127bc..f92712a89127 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -16,20 +16,30 @@ export const TabbedLayout = (props) => { -
- + + {tabOptions.map((option) => ( ))} -
+
+ {children}
- {children}
); }; diff --git a/src/layouts/config.js b/src/layouts/config.js index 115de1795d46..ce0edeb5cdd9 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -31,7 +31,6 @@ export const nativeMenuItems = [ items: [ { title: "Administration", - path: "/identity/administration", permissions: ["Identity.User.*"], items: [ { @@ -83,7 +82,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/identity/reports", permissions: [ "Identity.User.*", "Identity.Group.*", @@ -133,7 +131,6 @@ export const nativeMenuItems = [ items: [ { title: "Administration", - path: "/tenant/administration", permissions: ["Tenant.Administration.*"], items: [ { @@ -180,12 +177,11 @@ export const nativeMenuItems = [ }, { title: "GDAP Management", - path: "/tenant/gdap-management/", + path: "/tenant/gdap-management", permissions: ["Tenant.Relationship.*"], }, { title: "Standards & Drift", - path: "/tenant/standards", permissions: [ "Tenant.Standards.*", "Tenant.BestPracticeAnalyser.*", @@ -211,7 +207,6 @@ export const nativeMenuItems = [ }, { title: "Conditional Access", - path: "/tenant/conditional", permissions: ["Tenant.ConditionalAccess.*"], items: [ { @@ -238,7 +233,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/tenant/reports", permissions: ["Tenant.Administration.*", "Scheduler.Billing.*", "Tenant.Application.*"], items: [ { @@ -258,6 +252,11 @@ export const nativeMenuItems = [ }, ], }, + { + title: "Manage Tenant", + path: "/tenant/manage/edit", + permissions: ["Tenant.Administration.*"], + }, ], }, { @@ -277,7 +276,6 @@ export const nativeMenuItems = [ items: [ { title: "Incidents & Alerts", - path: "/security/incidents", permissions: ["Security.Incident.*"], items: [ { @@ -304,7 +302,6 @@ export const nativeMenuItems = [ }, { title: "Defender", - path: "/security/defender", permissions: ["Security.Alert.*"], items: [ { @@ -326,7 +323,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/security/reports", permissions: ["Tenant.DeviceCompliance.*"], items: [ { @@ -338,7 +334,6 @@ export const nativeMenuItems = [ }, { title: "Safe Links", - path: "/security/safelinks", permissions: ["Security.SafeLinksPolicy.*"], items: [ { @@ -373,7 +368,6 @@ export const nativeMenuItems = [ items: [ { title: "Applications", - path: "/endpoint/applications", permissions: ["Endpoint.Application.*"], items: [ { @@ -390,7 +384,6 @@ export const nativeMenuItems = [ }, { title: "Autopilot", - path: "/endpoint/autopilot", permissions: ["Endpoint.Autopilot.*"], items: [ { @@ -417,7 +410,6 @@ export const nativeMenuItems = [ }, { title: "Device Management", - path: "/endpoint/MEM", permissions: ["Endpoint.MEM.*"], items: [ { @@ -464,7 +456,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/endpoint/reports", permissions: ["Endpoint.Device.*", "Endpoint.Autopilot.*"], items: [ { @@ -514,7 +505,6 @@ export const nativeMenuItems = [ }, { title: "Teams", - path: "/teams-share/teams", permissions: ["Teams.Group.*"], items: [ { @@ -560,7 +550,6 @@ export const nativeMenuItems = [ items: [ { title: "Administration", - path: "/email/administration", permissions: ["Exchange.Mailbox.*"], items: [ { @@ -612,7 +601,6 @@ export const nativeMenuItems = [ }, { title: "Transport", - path: "/email/transport", permissions: ["Exchange.TransportRule.*"], items: [ { @@ -639,7 +627,6 @@ export const nativeMenuItems = [ }, { title: "Spamfilter", - path: "/email/spamfilter", permissions: ["Exchange.SpamFilter.*"], items: [ { @@ -671,7 +658,6 @@ export const nativeMenuItems = [ }, { title: "Resource Management", - path: "/email/resources/management", permissions: ["Exchange.Equipment.*"], items: [ { @@ -693,7 +679,6 @@ export const nativeMenuItems = [ }, { title: "Reports", - path: "/email/reports", permissions: [ "Exchange.Mailbox.*", "Exchange.SpamFilter.*", @@ -763,7 +748,6 @@ export const nativeMenuItems = [ items: [ { title: "Tenant Tools", - path: "/tenant/tools", permissions: ["Tenant.Administration.*"], items: [ { @@ -797,7 +781,6 @@ export const nativeMenuItems = [ }, { title: "Email Tools", - path: "/email/tools", permissions: ["Exchange.Mailbox.*"], items: [ { @@ -819,7 +802,6 @@ export const nativeMenuItems = [ }, { title: "Dark Web Tools", - path: "/tools/darkweb", permissions: ["CIPP.Core.*"], items: [ { diff --git a/src/layouts/index.js b/src/layouts/index.js index 5813fc0ee70e..8508f200f287 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -1,6 +1,15 @@ import { useCallback, useEffect, useState, useRef } from "react"; import { usePathname } from "next/navigation"; -import { Alert, Button, Dialog, DialogContent, DialogTitle, useMediaQuery } from "@mui/material"; +import { + Alert, + Button, + Dialog, + Divider, + DialogContent, + DialogTitle, + useMediaQuery, +} from "@mui/material"; +import { Stack } from "@mui/system"; import { styled } from "@mui/material/styles"; import { useSettings } from "../hooks/use-settings"; import { Footer } from "./footer"; @@ -15,6 +24,7 @@ import { CippImageCard } from "../components/CippCards/CippImageCard"; import Page from "../pages/onboardingv2"; import { useDialog } from "../hooks/use-dialog"; import { nativeMenuItems } from "/src/layouts/config"; +import { CippBreadcrumbNav } from "../components/CippComponents/CippBreadcrumbNav"; const SIDE_NAV_WIDTH = 270; const SIDE_NAV_PINNED_WIDTH = 50; @@ -314,8 +324,9 @@ export const Layout = (props) => { )} {(currentTenant === "AllTenants" || !currentTenant) && !allTenantsSupport ? ( - + + { ) : ( - <>{children} + + + + + + {children} + )}