diff --git a/.gitignore b/.gitignore index 78ea4526e7ce..44dac6dd492c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,10 @@ app.log # AI rules .*/rules AGENTS.md + +# jetbrains +.idea + +# azurite +__* +AzuriteConfig diff --git a/package.json b/package.json index 1fe17fc166a6..6c8daea53c0f 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,8 @@ }, "devDependencies": { "@svgr/webpack": "8.1.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "eslint": "9.39.2", "eslint-config-next": "16.1.6" } diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index 0f0d6e88ad8b..caafb71bab40 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useMemo } from "react"; import { TextField, Box, @@ -18,22 +18,123 @@ import { useRouter } from "next/router"; import { BulkActionsMenu } from "../bulk-actions-menu"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippBitlockerKeySearch } from "../CippComponents/CippBitlockerKeySearch"; +import { nativeMenuItems } from "../../layouts/config"; +import { usePermissions } from "../../hooks/use-permissions"; + +function getLeafItems(items = []) { + let result = []; + + for (const item of items) { + if (item.items && Array.isArray(item.items) && item.items.length > 0) { + result = result.concat(getLeafItems(item.items)); + } else { + result.push(item); + } + } + + return result; +} + +async function loadTabOptions() { + const tabOptionPaths = [ + "/email/administration/exchange-retention", + "/cipp/custom-data", + "/cipp/super-admin", + "/tenant/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(`../../pages${basePath}/tabOptions.json`); + const options = module.default || module; + + options.forEach((option) => { + tabOptions.push({ + title: option.label, + path: option.path, + type: "tab", + basePath, + }); + }); + } catch (error) { + console.debug(`Could not load tabOptions for ${basePath}:`, error); + } + } + + return tabOptions; +} + +function filterItemsByPermissionsAndRoles(items, userPermissions, userRoles) { + return items.filter((item) => { + if (item.permissions && item.permissions.length > 0) { + const hasPermission = userPermissions?.some((userPerm) => { + return item.permissions.some((requiredPerm) => { + if (userPerm === requiredPerm) { + return true; + } + + if (requiredPerm.includes("*")) { + const regexPattern = requiredPerm + .replace(/\\/g, "\\\\") + .replace(/\./g, "\\.") + .replace(/\*/g, ".*"); + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(userPerm); + } + + return false; + }); + }); + if (!hasPermission) { + return false; + } + } + + return true; + }); +} export const CippUniversalSearchV2 = React.forwardRef( - ({ onConfirm = () => {}, onChange = () => {}, maxResults = 10, value = "" }, ref) => { + ( + { + onConfirm = () => {}, + onChange = () => {}, + maxResults = 10, + value = "", + autoFocus = false, + defaultSearchType = "Users", + }, + ref, + ) => { const [searchValue, setSearchValue] = useState(value); - const [searchType, setSearchType] = useState("Users"); + const [searchType, setSearchType] = useState(defaultSearchType); const [bitlockerLookupType, setBitlockerLookupType] = useState("keyId"); + const [tabOptions, setTabOptions] = useState([]); const [showDropdown, setShowDropdown] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); const [bitlockerDrawerVisible, setBitlockerDrawerVisible] = useState(false); const [bitlockerDrawerDefaults, setBitlockerDrawerDefaults] = useState({ searchTerm: "", searchType: "keyId", }); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); + const [dropdownMaxHeight, setDropdownMaxHeight] = useState(400); const containerRef = useRef(null); const textFieldRef = useRef(null); + const dropdownRef = useRef(null); const router = useRouter(); + const { userPermissions, userRoles } = usePermissions(); const universalSearch = ApiGetCall({ url: `/api/ExecUniversalSearchV2`, @@ -55,7 +156,110 @@ export const CippUniversalSearchV2 = React.forwardRef( waiting: false, }); - const activeSearch = searchType === "BitLocker" ? bitlockerSearch : universalSearch; + const activeSearch = + searchType === "BitLocker" ? bitlockerSearch : searchType === "Pages" ? null : universalSearch; + + const flattenedMenuItems = useMemo(() => { + const allLeafItems = getLeafItems(nativeMenuItems); + + const buildBreadcrumbPath = (items, targetPath) => { + const searchRecursive = (nestedItems, currentPath = []) => { + for (const item of nestedItems) { + const shouldAddToPath = item.title !== "Dashboard" || item.path !== "/"; + const newPath = shouldAddToPath ? [...currentPath, item.title] : currentPath; + + if (item.path) { + const normalizedItemPath = item.path.replace(/\/$/, ""); + const normalizedTargetPath = targetPath.replace(/\/$/, ""); + + if (normalizedItemPath !== "/" && normalizedItemPath.startsWith(normalizedTargetPath)) { + return newPath; + } + } + + 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) || []; + const trimmedBreadcrumbs = + rawBreadcrumbs.length > 0 && rawBreadcrumbs[rawBreadcrumbs.length - 1] === item.title + ? rawBreadcrumbs.slice(0, -1) + : rawBreadcrumbs; + return { + ...item, + breadcrumbs: trimmedBreadcrumbs, + }; + }); + + const leafItemIndex = allLeafItems.reduce((acc, item) => { + if (item.path) { + acc[item.path.replace(/\/$/, "")] = item; + } + return acc; + }, {}); + + const filteredTabOptions = tabOptions + .map((tab) => { + const normalizedTabPath = tab.path.replace(/\/$/, ""); + const normalizedBasePath = tab.basePath?.replace(/\/$/, ""); + + let pageItem = leafItemIndex[normalizedTabPath]; + + if (!pageItem && normalizedBasePath) { + pageItem = allLeafItems.find((item) => { + const normalizedItemPath = item.path?.replace(/\/$/, ""); + return normalizedItemPath && normalizedItemPath.startsWith(normalizedBasePath); + }); + } + + if (!pageItem) return null; + + const hasAccessToPage = + filterItemsByPermissionsAndRoles([pageItem], userPermissions, userRoles).length > 0; + if (!hasAccessToPage) return null; + + const breadcrumbs = buildBreadcrumbPath(nativeMenuItems, pageItem.path) || []; + 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 normalizedSearch = searchValue.trim().toLowerCase(); + const pageResults = 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), + ); + const inScope = (leaf.scope === "global" ? "global" : "tenant").includes(normalizedSearch); + + return normalizedSearch ? inTitle || inPath || inBreadcrumbs || inScope : false; + }); const handleChange = (event) => { const newValue = event.target.value; @@ -64,21 +268,60 @@ export const CippUniversalSearchV2 = React.forwardRef( if (newValue.length === 0) { setShowDropdown(false); + } else if (searchType === "Pages") { + updateDropdownPosition(); + setShowDropdown(true); } }; const updateDropdownPosition = () => { if (textFieldRef.current) { const rect = textFieldRef.current.getBoundingClientRect(); + const availableHeight = Math.max(220, window.innerHeight - rect.bottom - 16); setDropdownPosition({ top: rect.bottom + window.scrollY + 4, left: rect.left + window.scrollX, width: rect.width, }); + setDropdownMaxHeight(availableHeight); } }; const handleKeyDown = (event) => { + if (event.key === "Escape" && showDropdown) { + event.preventDefault(); + setShowDropdown(false); + setHighlightedIndex(-1); + return; + } + + if ((event.key === "ArrowDown" || event.key === "ArrowUp") && showDropdown && hasResults) { + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + const total = activeResults.length; + setHighlightedIndex((prev) => { + if (prev < 0) { + return direction === 1 ? 0 : total - 1; + } + return (prev + direction + total) % total; + }); + return; + } + + if (event.key === "Enter" && showDropdown && hasResults && highlightedIndex >= 0) { + event.preventDefault(); + const selectedItem = activeResults[highlightedIndex]; + if (!selectedItem) { + return; + } + if (searchType === "BitLocker") { + handleBitlockerResultClick(selectedItem); + } else { + handleResultClick(selectedItem); + } + return; + } + if (event.key === "Enter" && searchValue.length > 0) { handleSearch(); } @@ -87,7 +330,9 @@ export const CippUniversalSearchV2 = React.forwardRef( const handleSearch = () => { if (searchValue.length > 0) { updateDropdownPosition(); - activeSearch.refetch(); + if (searchType !== "Pages") { + activeSearch?.refetch(); + } setShowDropdown(true); } }; @@ -103,8 +348,12 @@ export const CippUniversalSearchV2 = React.forwardRef( router.push( `/identity/administration/groups/group?groupId=${itemData.id}&tenantFilter=${tenantDomain}`, ); + } else if (searchType === "Pages") { + router.push(match.path, undefined, { shallow: true }); } + setSearchValue(""); setShowDropdown(false); + onConfirm(match); }; const handleTypeChange = (type) => { @@ -124,7 +373,9 @@ export const CippUniversalSearchV2 = React.forwardRef( searchType: bitlockerLookupType, }); setBitlockerDrawerVisible(true); + setSearchValue(""); setShowDropdown(false); + onConfirm(match); }; const typeMenuActions = [ @@ -143,6 +394,11 @@ export const CippUniversalSearchV2 = React.forwardRef( icon: "FilePresent", onClick: () => handleTypeChange("BitLocker"), }, + { + label: "Pages", + icon: "GlobeAltIcon", + onClick: () => handleTypeChange("Pages"), + }, ]; const bitlockerLookupActions = [ @@ -193,12 +449,51 @@ export const CippUniversalSearchV2 = React.forwardRef( } }, [showDropdown]); + useEffect(() => { + setHighlightedIndex(-1); + }, [searchType, searchValue, showDropdown]); + + useEffect(() => { + if (!showDropdown || highlightedIndex < 0 || !dropdownRef.current) { + return; + } + + const activeRow = dropdownRef.current.querySelector( + `[data-result-index="${highlightedIndex}"]`, + ); + + if (activeRow && typeof activeRow.scrollIntoView === "function") { + activeRow.scrollIntoView({ block: "nearest" }); + } + }, [highlightedIndex, showDropdown]); + + useEffect(() => { + loadTabOptions().then(setTabOptions); + }, []); + + useEffect(() => { + setSearchType(defaultSearchType); + if (defaultSearchType === "BitLocker") { + setBitlockerLookupType("keyId"); + } + }, [defaultSearchType]); + const bitlockerResults = Array.isArray(bitlockerSearch?.data?.Results) ? bitlockerSearch.data.Results : []; const universalResults = Array.isArray(universalSearch?.data) ? universalSearch.data : []; + const activeResults = + searchType === "BitLocker" + ? bitlockerResults + : searchType === "Pages" + ? pageResults + : universalResults; const hasResults = - searchType === "BitLocker" ? bitlockerResults.length > 0 : universalResults.length > 0; + searchType === "BitLocker" + ? bitlockerResults.length > 0 + : searchType === "Pages" + ? pageResults.length > 0 + : universalResults.length > 0; const shouldShowDropdown = showDropdown && searchValue.length > 0; const getLabel = () => { @@ -210,13 +505,15 @@ export const CippUniversalSearchV2 = React.forwardRef( return bitlockerLookupType === "deviceId" ? "Search BitLocker by Device ID" : "Search BitLocker by Recovery Key ID"; + } else if (searchType === "Pages") { + return "Search pages, tabs, paths, or scope"; } return "Search"; }; return ( <> - + ), - endAdornment: activeSearch.isFetching ? ( + endAdornment: activeSearch?.isFetching ? ( @@ -264,28 +562,31 @@ export const CippUniversalSearchV2 = React.forwardRef( }, }} /> - + {searchType !== "Pages" && ( + + )} {shouldShowDropdown && ( - {activeSearch.isFetching ? ( + {activeSearch?.isFetching ? ( @@ -303,6 +604,16 @@ export const CippUniversalSearchV2 = React.forwardRef( + ) : searchType === "Pages" ? ( + ) : ( ) ) : ( @@ -343,7 +656,14 @@ export const CippUniversalSearchV2 = React.forwardRef( CippUniversalSearchV2.displayName = "CippUniversalSearchV2"; -const Results = ({ items = [], searchValue, onResultClick, searchType = "Users" }) => { +const Results = ({ + items = [], + searchValue, + onResultClick, + searchType = "Users", + highlightedIndex = -1, + setHighlightedIndex = () => {}, +}) => { const highlightMatch = (text) => { if (!text || !searchValue) return text; const escapedSearch = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -368,12 +688,16 @@ const Results = ({ items = [], searchValue, onResultClick, searchType = "Users" return ( onResultClick(match)} + onMouseEnter={() => setHighlightedIndex(index)} + selected={highlightedIndex === index} sx={{ py: 1.5, px: 2, borderBottom: index < items.length - 1 ? "1px solid" : "none", borderColor: "divider", + backgroundColor: highlightedIndex === index ? "action.selected" : "transparent", "&:hover": { backgroundColor: "action.hover", }, @@ -423,18 +747,115 @@ const Results = ({ items = [], searchValue, onResultClick, searchType = "Users" ); }; -const BitlockerResults = ({ items = [], onResultClick }) => { +const PageResults = ({ + items = [], + searchValue, + onResultClick, + highlightedIndex = -1, + setHighlightedIndex = () => {}, +}) => { + const highlightMatch = (text = "") => { + if (!text || !searchValue) return text; + const escapedSearch = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = text.split(new RegExp(`(${escapedSearch})`, "gi")); + return parts.map((part, index) => + part.toLowerCase() === searchValue.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ); + }; + + return ( + <> + {items.map((item, index) => { + const isGlobal = item.scope === "global"; + const itemType = item.type === "tab" ? "Tab" : "Page"; + + return ( + onResultClick(item)} + onMouseEnter={() => setHighlightedIndex(index)} + selected={highlightedIndex === index} + sx={{ + py: 1.5, + px: 2, + borderBottom: index < items.length - 1 ? "1px solid" : "none", + borderColor: "divider", + alignItems: "flex-start", + backgroundColor: highlightedIndex === index ? "action.selected" : "transparent", + "&:hover": { + backgroundColor: "action.hover", + }, + }} + > + + + {highlightMatch(item.title || "")} + + + {itemType} + + + {isGlobal ? "Global" : "Tenant"} + + + } + secondary={ + + {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 || "")} + + + } + /> + + ); + })} + + ); +}; + +const BitlockerResults = ({ + items = [], + onResultClick, + highlightedIndex = -1, + setHighlightedIndex = () => {}, +}) => { return ( <> {items.map((result, index) => ( onResultClick(result)} + onMouseEnter={() => setHighlightedIndex(index)} + selected={highlightedIndex === index} sx={{ py: 1.5, px: 2, borderBottom: index < items.length - 1 ? "1px solid" : "none", borderColor: "divider", + backgroundColor: highlightedIndex === index ? "action.selected" : "transparent", "&:hover": { backgroundColor: "action.hover", }, diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index 777fc9d07283..1c4ca364c362 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -244,7 +244,7 @@ export const CippApiResults = (props) => { const hasVisibleResults = finalResults.some((r) => r.visible); return ( - + {/* Loading alert */} {!errorsOnly && ( diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx index 410be49561c0..7d6d2366301f 100644 --- a/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -869,22 +869,55 @@ export const CippApplicationDeployDrawer = ({ rows={6} /> - - - - + + + + + + + + + + + + + + 0) { - // recurse into children - result = result.concat(getLeafItems(item.items)); - } else { - // no child items => this is a leaf - result.push(item); - } - } - - 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", - "/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(`../../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 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; - }, {}); - - // 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); - }; - - // Optionally handle Enter key - const handleKeyDown = (event) => { - if (event.key === "Enter") { - // do something if needed, e.g., analytics or highlight - } - }; - - // 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), - ); - const inScope = (leaf.scope === "global" ? "global" : "tenant").includes(normalizedSearch); - // If there's no search value, show no results (you could change this logic) - return normalizedSearch ? inTitle || inPath || inBreadcrumbs || inScope : false; - }); - - // Helper to bold‐highlight the matched text - const highlightMatch = (text = "") => { - if (!normalizedSearch) return text; - const parts = text.split(new RegExp(`(${normalizedSearch})`, "gi")); - return parts.map((part, i) => - part.toLowerCase() === normalizedSearch ? ( - - {part} - - ) : ( - part - ), - ); - }; - - // 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 }); - handleClose(); - }; - - return ( - - CIPP Search - - - - { - // Select all text on focus if there's content - if (event.target.value) { - event.target.select(); - } - }} - value={searchValue} - autoFocus - /> - - {/* Show results or "No results" */} - {searchValue.trim().length > 0 ? ( - filteredItems.length > 0 ? ( - - {filteredItems.map((item, index) => { - const isGlobal = item.scope === "global"; - return ( - - - handleCardClick(item.path)} - aria-label={`Navigate to ${item.title}`} - > - - - {highlightMatch(item.title)} - - {getItemTypeLabel(item)} - - - {isGlobal ? "Global" : "Tenant"} - - - {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)} - - - - - - ); - })} - - ) : ( - No results found. - ) - ) : ( - - Type something to search by title or path. - - )} - - - - - - - ); -}; diff --git a/src/components/CippComponents/CippNotificationForm.jsx b/src/components/CippComponents/CippNotificationForm.jsx index 9f499e4ac4c1..03ecce096ec9 100644 --- a/src/components/CippComponents/CippNotificationForm.jsx +++ b/src/components/CippComponents/CippNotificationForm.jsx @@ -2,17 +2,20 @@ import { useEffect } from "react"; import { Button, Box } from "@mui/material"; import { Grid } from "@mui/system"; import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; import { ApiGetCall } from "../../api/ApiCall"; import { useDialog } from "../../hooks/use-dialog"; import { CippApiDialog } from "./CippApiDialog"; import { useFormState } from "react-hook-form"; +import { useSettings } from "../../hooks/use-settings"; export const CippNotificationForm = ({ formControl, showTestButton = true, - hideButtons = false, }) => { const notificationDialog = useDialog(); + const settings = useSettings(); + const currentTenant = settings.currentTenant; // API call to get notification configuration const listNotificationConfig = ApiGetCall({ @@ -49,6 +52,14 @@ export const CippNotificationForm = ({ { label: "Critical", value: "Critical" }, ]; + const webhookAuthTypes = [ + { label: "None", value: "None" }, + { label: "Bearer Token", value: "Bearer" }, + { label: "Basic Auth", value: "Basic" }, + { label: "API Key Header", value: "ApiKey" }, + { label: "Custom Headers (JSON)", value: "CustomHeaders" }, + ]; + // Load notification config data into form useEffect(() => { if (listNotificationConfig.isSuccess) { @@ -69,9 +80,19 @@ export const CippNotificationForm = ({ onePerTenant: listNotificationConfig.data?.onePerTenant, sendtoIntegration: listNotificationConfig.data?.sendtoIntegration, includeTenantId: listNotificationConfig.data?.includeTenantId, + UseStandardizedSchema: listNotificationConfig.data?.UseStandardizedSchema || false, + webhookAuthType: webhookAuthTypes.find( + (type) => type.value === listNotificationConfig.data?.webhookAuthType, + ) || webhookAuthTypes[0], + webhookAuthToken: listNotificationConfig.data?.webhookAuthToken, + webhookAuthUsername: listNotificationConfig.data?.webhookAuthUsername, + webhookAuthPassword: listNotificationConfig.data?.webhookAuthPassword, + webhookAuthHeaderName: listNotificationConfig.data?.webhookAuthHeaderName, + webhookAuthHeaderValue: listNotificationConfig.data?.webhookAuthHeaderValue, + webhookAuthHeaders: listNotificationConfig.data?.webhookAuthHeaders, }); } - }, [listNotificationConfig.isSuccess]); + }, [listNotificationConfig.isSuccess, listNotificationConfig.dataUpdatedAt]); return ( <> @@ -98,6 +119,117 @@ export const CippNotificationForm = ({ helperText="Enter the webhook URL to send notifications to. The URL should be configured to receive a POST request." /> + + + + + + + + + + + <> + + + + + + + + + + + <> + + + + + + + + + + + + + + + {showTestButton && ( @@ -185,6 +325,7 @@ export const CippNotificationForm = ({ type: "POST", dataFunction: (row) => ({ ...row, + tenantFilter: currentTenant, text: "This is a test from Notification Settings", }), }} diff --git a/src/components/CippComponents/CippTextFieldWithVariables.jsx b/src/components/CippComponents/CippTextFieldWithVariables.jsx index 80d720ac9440..6c083ead4be1 100644 --- a/src/components/CippComponents/CippTextFieldWithVariables.jsx +++ b/src/components/CippComponents/CippTextFieldWithVariables.jsx @@ -33,7 +33,12 @@ export const CippTextFieldWithVariables = ({ // Track cursor position const handleSelectionChange = () => { if (textFieldRef.current) { - setCursorPosition(textFieldRef.current.selectionStart || 0); + // Check for input first, then textarea + const inputElement = + textFieldRef.current.querySelector("input") || + textFieldRef.current.querySelector("textarea") || + textFieldRef.current; + setCursorPosition(inputElement?.selectionStart || 0); } }; @@ -97,7 +102,11 @@ export const CippTextFieldWithVariables = ({ // Get fresh cursor position from the DOM let cursorPos = cursorPosition; if (textFieldRef.current) { - const inputElement = textFieldRef.current.querySelector("input") || textFieldRef.current; + // Check for input first, then textarea + const inputElement = + textFieldRef.current.querySelector("input") || + textFieldRef.current.querySelector("textarea") || + textFieldRef.current; if (inputElement && typeof inputElement.selectionStart === "number") { cursorPos = inputElement.selectionStart; } @@ -128,8 +137,11 @@ export const CippTextFieldWithVariables = ({ const newCursorPos = lastPercentIndex + variableString.length; // Access the actual input element for Material-UI TextField + // Check for input first, then textarea const inputElement = - textFieldRef.current.querySelector("input") || textFieldRef.current; + textFieldRef.current.querySelector("input") || + textFieldRef.current.querySelector("textarea") || + textFieldRef.current; if (inputElement && inputElement.setSelectionRange) { inputElement.setSelectionRange(newCursorPos, newCursorPos); inputElement.focus(); diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index 0b365b506c77..c3346551de31 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -4,6 +4,7 @@ import { Archive, Clear, CloudDone, + ContentCopy, Edit, Email, ForwardToInbox, @@ -23,8 +24,9 @@ import { import { getCippLicenseTranslation } from "../../utils/get-cipp-license-translation"; import { useSettings } from "../../hooks/use-settings.js"; import { usePermissions } from "../../hooks/use-permissions"; -import { Tooltip, Box } from "@mui/material"; +import { Tooltip, Box, Divider, Typography } from "@mui/material"; import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; import { useWatch } from "react-hook-form"; // Separate component for Manage Licenses form to avoid hook issues @@ -257,6 +259,62 @@ const OutOfOfficeForm = ({ formControl }) => { multiline rows={4} /> + + {!areDateFieldsDisabled && ( + <> + + Calendar Options + + + + + + + + + + + + + + )} ); }; @@ -287,6 +345,55 @@ export const useCippUserActions = () => { target: "_self", condition: () => canWriteUser, }, + { + label: "Create Template from User", + type: "POST", + icon: , + url: "/api/AddUserDefaults", + fields: [ + { + type: "textField", + name: "templateName", + label: "Template Name", + validators: { required: "Please enter a template name" }, + }, + { + type: "switch", + name: "defaultForTenant", + label: "Default for Tenant", + }, + ], + customDataformatter: (row, action, formData) => { + const user = Array.isArray(row) ? row[0] : row; + const licenses = + user.assignedLicenses?.map((l) => ({ + label: getCippLicenseTranslation([l])?.[0] || l.skuId, + value: l.skuId, + })) || []; + return { + tenantFilter: tenant, + templateName: formData.templateName, + defaultForTenant: formData.defaultForTenant || false, + sourceUserId: user.id, + jobTitle: user.jobTitle || "", + department: user.department || "", + streetAddress: user.streetAddress || "", + city: user.city || "", + state: user.state || "", + postalCode: user.postalCode || "", + country: user.country || "", + companyName: user.companyName || "", + mobilePhone: user.mobilePhone || "", + "businessPhones[0]": user.businessPhones?.[0] || "", + usageLocation: user.usageLocation || "", + licenses: licenses, + }; + }, + confirmText: + "Create a new user default template based on [displayName]'s properties (job title, department, location, licenses, and group memberships).", + multiPost: false, + condition: () => canWriteUser, + }, { //tested label: "Research Compromised Account", diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index b479cacd197a..7591e47fd819 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -86,7 +86,13 @@ const CippAddEditUser = (props) => { const watcher = useWatch({ control: formControl.control }); // Helper function to generate username from template format - const generateUsername = (format, firstName, lastName) => { + const generateUsername = ( + format, + firstName, + lastName, + spaceHandling = "keep", + spaceReplacement = "", + ) => { if (!format || !firstName || !lastName) return ""; // Ensure format is a string @@ -108,6 +114,13 @@ const CippAddEditUser = (props) => { username = username.replace(/%FirstName%/gi, firstName); username = username.replace(/%LastName%/gi, lastName); + // Apply optional space handling + if (spaceHandling === "remove") { + username = username.replace(/\s+/g, ""); + } else if (spaceHandling === "replace") { + username = username.replace(/\s+/g, spaceReplacement || ""); + } + // Convert to lowercase return username.toLowerCase(); }; @@ -152,10 +165,26 @@ const CippAddEditUser = (props) => { : selectedTemplate.usernameFormat?.value || selectedTemplate.usernameFormat?.label; if (formatString) { + const spaceHandling = + typeof selectedTemplate.usernameSpaceHandling === "string" + ? selectedTemplate.usernameSpaceHandling + : selectedTemplate.usernameSpaceHandling?.value || + selectedTemplate.usernameSpaceHandling?.label || + "keep"; + + const spaceReplacement = + typeof selectedTemplate.usernameSpaceReplacement === "string" + ? selectedTemplate.usernameSpaceReplacement + : selectedTemplate.usernameSpaceReplacement?.value || + selectedTemplate.usernameSpaceReplacement?.label || + ""; + const generatedUsername = generateUsername( formatString, watcher.givenName, watcher.surname, + spaceHandling, + spaceReplacement, ); if (generatedUsername) { formControl.setValue("username", generatedUsername, { shouldDirty: true }); @@ -249,6 +278,11 @@ const CippAddEditUser = (props) => { if (template.licenses && Array.isArray(template.licenses)) { setFieldIfEmpty("licenses", template.licenses); } + + // Pass stored group memberships from template to user creation + if (template.groupMemberships && Array.isArray(template.groupMemberships) && template.groupMemberships.length > 0) { + formControl.setValue("groupMemberships", template.groupMemberships); + } } }, [watcher.userTemplate, formType]); diff --git a/src/components/CippFormPages/CippExchangeSettingsForm.jsx b/src/components/CippFormPages/CippExchangeSettingsForm.jsx index ee8bfc143074..ee44517b8c51 100644 --- a/src/components/CippFormPages/CippExchangeSettingsForm.jsx +++ b/src/components/CippFormPages/CippExchangeSettingsForm.jsx @@ -14,6 +14,7 @@ import { } from "@mui/material"; import { Check, Error, Sync } from "@mui/icons-material"; import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; import { Grid } from "@mui/system"; @@ -81,6 +82,11 @@ const CippExchangeSettingsForm = (props) => { "ExternalMessage", "StartTime", "EndTime", + "CreateOOFEvent", + "OOFEventSubject", + "AutoDeclineFutureRequestsWhenOOF", + "DeclineEventsForScheduledOOF", + "DeclineMeetingMessage", ]; // Reset the form @@ -266,6 +272,72 @@ const CippExchangeSettingsForm = (props) => { rows={4} /> + {!areDateFieldsDisabled && ( + <> + + + + Calendar Options + + + + + + + + + + + + + + + + + + + + + + + )} diff --git a/src/components/CippWizard/CippWizardVacationActions.jsx b/src/components/CippWizard/CippWizardVacationActions.jsx index c7376a1528da..1d8ac1611af7 100644 --- a/src/components/CippWizard/CippWizardVacationActions.jsx +++ b/src/components/CippWizard/CippWizardVacationActions.jsx @@ -42,6 +42,22 @@ export const CippWizardVacationActions = (props) => { if (!currentExternal) { formControl.setValue("oooExternalMessage", oooData.data.ExternalMessage || ""); } + // Pre-populate calendar options from existing config + if (oooData.data.CreateOOFEvent != null) { + formControl.setValue("oooCreateOOFEvent", !!oooData.data.CreateOOFEvent); + } + if (oooData.data.OOFEventSubject) { + formControl.setValue("oooOOFEventSubject", oooData.data.OOFEventSubject); + } + if (oooData.data.AutoDeclineFutureRequestsWhenOOF != null) { + formControl.setValue("oooAutoDeclineFutureRequests", !!oooData.data.AutoDeclineFutureRequestsWhenOOF); + } + if (oooData.data.DeclineEventsForScheduledOOF != null) { + formControl.setValue("oooDeclineEvents", !!oooData.data.DeclineEventsForScheduledOOF); + } + if (oooData.data.DeclineMeetingMessage) { + formControl.setValue("oooDeclineMeetingMessage", oooData.data.DeclineMeetingMessage); + } } }, [oooData.isSuccess, oooData.data]); @@ -359,6 +375,70 @@ export const CippWizardVacationActions = (props) => { /> )} + + {/* Calendar Options */} + + + + Calendar Options + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/CippWizard/CippWizardVacationConfirmation.jsx b/src/components/CippWizard/CippWizardVacationConfirmation.jsx index da86c414feb0..70a55423a6fb 100644 --- a/src/components/CippWizard/CippWizardVacationConfirmation.jsx +++ b/src/components/CippWizard/CippWizardVacationConfirmation.jsx @@ -58,18 +58,31 @@ export const CippWizardVacationConfirmation = (props) => { } if (values.enableOOO) { + const oooData = { + tenantFilter, + Users: values.Users, + internalMessage: values.oooInternalMessage, + externalMessage: values.oooExternalMessage, + startDate: values.startDate, + endDate: values.endDate, + reference: values.reference || null, + postExecution: values.postExecution || [], + }; + // Calendar options — only include when truthy + if (values.oooCreateOOFEvent) { + oooData.CreateOOFEvent = true; + if (values.oooOOFEventSubject) oooData.OOFEventSubject = values.oooOOFEventSubject; + } + if (values.oooAutoDeclineFutureRequests) { + oooData.AutoDeclineFutureRequestsWhenOOF = true; + } + if (values.oooDeclineEvents) { + oooData.DeclineEventsForScheduledOOF = true; + if (values.oooDeclineMeetingMessage) oooData.DeclineMeetingMessage = values.oooDeclineMeetingMessage; + } oooVacation.mutate({ url: "/api/ExecScheduleOOOVacation", - data: { - tenantFilter, - Users: values.Users, - internalMessage: values.oooInternalMessage, - externalMessage: values.oooExternalMessage, - startDate: values.startDate, - endDate: values.endDate, - reference: values.reference || null, - postExecution: values.postExecution || [], - }, + data: oooData, }); } }; @@ -244,6 +257,24 @@ export const CippWizardVacationConfirmation = (props) => { )} + {(values.oooCreateOOFEvent || values.oooAutoDeclineFutureRequests || values.oooDeclineEvents) && ( +
+ + Calendar Options + + + {values.oooCreateOOFEvent && ( + + )} + {values.oooAutoDeclineFutureRequests && ( + + )} + {values.oooDeclineEvents && ( + + )} + +
+ )}
diff --git a/src/components/ExecutiveReportButton.js b/src/components/ExecutiveReportButton.js index e25d22d56b11..64d2609015af 100644 --- a/src/components/ExecutiveReportButton.js +++ b/src/components/ExecutiveReportButton.js @@ -2709,12 +2709,14 @@ export const ExecutiveReportButton = (props) => { // Fetch organization data - only when preview is open const organization = ApiGetCall({ - url: "/api/ListOrg", - queryKey: `${settings.currentTenant}-ListOrg-report`, - data: { tenantFilter: settings.currentTenant }, + url: "/api/ListGraphRequest", + queryKey: `${settings.currentTenant}-ListGraphRequest-organization-report`, + data: { tenantFilter: settings.currentTenant, Endpoint: "organization" }, waiting: previewOpen, }); + const organizationRecord = organization.data?.Results?.[0]; + // Fetch user counts - only when preview is open const dashboard = ApiGetCall({ url: "/api/ListuserCounts", @@ -2738,11 +2740,12 @@ export const ExecutiveReportButton = (props) => { // Get real device data - only when preview is open const deviceData = ApiGetCall({ - url: "/api/ListDevices", + url: "/api/ListGraphRequest", data: { tenantFilter: settings.currentTenant, + Endpoint: "deviceManagement/managedDevices", }, - queryKey: `devices-report-${settings.currentTenant}`, + queryKey: `ListGraphRequest-devices-report-${settings.currentTenant}`, waiting: previewOpen, }); @@ -2812,8 +2815,8 @@ export const ExecutiveReportButton = (props) => { // Button is always available now since we don't need to wait for data const shouldShowButton = true; - const tenantName = organization.data?.displayName || "Tenant"; - const tenantId = organization.data?.id; + const tenantName = organizationRecord?.displayName || "Tenant"; + const tenantId = organizationRecord?.id; const userStats = { licensedUsers: dashboard.data?.LicUsers || 0, unlicensedUsers: @@ -2855,11 +2858,11 @@ export const ExecutiveReportButton = (props) => { tenantId={tenantId} userStats={userStats} standardsData={driftComplianceData.data} - organizationData={organization.data} + organizationData={organizationRecord} brandingSettings={brandingSettings} secureScoreData={secureScore.isSuccess ? secureScore : null} licensingData={licenseData.isSuccess ? licenseData?.data : null} - deviceData={deviceData.isSuccess ? deviceData?.data : null} + deviceData={deviceData.isSuccess ? deviceData?.data?.Results : null} conditionalAccessData={ conditionalAccessData.isSuccess ? conditionalAccessData?.data?.Results : null } @@ -2889,7 +2892,7 @@ export const ExecutiveReportButton = (props) => { tenantName, tenantId, userStats, - organization.data, + organizationRecord, dashboard.data, brandingSettings, secureScore?.isSuccess, @@ -3205,11 +3208,11 @@ export const ExecutiveReportButton = (props) => { tenantId={tenantId} userStats={userStats} standardsData={driftComplianceData.data} - organizationData={organization.data} + organizationData={organizationRecord} brandingSettings={brandingSettings} secureScoreData={secureScore.isSuccess ? secureScore : null} licensingData={licenseData.isSuccess ? licenseData?.data : null} - deviceData={deviceData.isSuccess ? deviceData?.data : null} + deviceData={deviceData.isSuccess ? deviceData?.data?.Results : null} conditionalAccessData={ conditionalAccessData.isSuccess ? conditionalAccessData?.data?.Results : null } diff --git a/src/data/alerts.json b/src/data/alerts.json index 7e93eb047e8b..77341e1c0b89 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -324,6 +324,16 @@ "label": "Alert on expiring application certificates", "recommendedRunInterval": "1d" }, + { + "name": "LongLivedAppCredentials", + "label": "Alert on long-lived app registration secrets or certificates", + "recommendedRunInterval": "1d", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Maximum allowed credential lifetime in months (1-24)", + "inputName": "InputValue", + "description": "Checks app registrations for client secrets or certificates that are valid longer than the Microsoft UI currently allows. Set a threshold from 1 to 24 months." + }, { "name": "ApnCertExpiry", "label": "Alert on expiring APN certificates", @@ -394,6 +404,12 @@ "label": "Alert on licensed users with any administrator roles", "recommendedRunInterval": "7d" }, + { + "name": "RoleEscalableGroups", + "label": "Alert on groups or nested groups assigned to a role that could be used for privilege escalation", + "recommendedRunInterval": "1d", + "description": "Scans for groups, including nested groups, that are assigned to directory roles and are not role assignable, which allows group owners or group admins to add more users to the group and gain access to a potentially privileged role." + }, { "name": "HuntressRogueApps", "label": "Alert on Huntress Rogue Apps detected", diff --git a/src/data/standards.json b/src/data/standards.json index fe08f2fcbb45..d68360bd98d3 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -5847,15 +5847,21 @@ "name": "standards.DeployCheckChromeExtension", "cat": "Intune Standards", "tag": [], - "helpText": "Deploys the Check Chrome extension via Intune OMA-URI custom policies for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg", - "docsDescription": "Creates Intune OMA-URI custom policies that automatically install and configure the Check Chrome extension on managed devices for both Google Chrome and Microsoft Edge browsers. This ensures the extension is deployed consistently across all corporate devices with customizable settings.", - "executiveText": "Automatically deploys the Check browser extension across all company devices with configurable security and branding settings, ensuring consistent security monitoring and compliance capabilities. This extension provides enhanced security features and monitoring tools that help protect against threats while maintaining user productivity.", + "helpText": "Deploys the Check by CyberDrain browser extension via a Win32 script app in Intune for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg", + "docsDescription": "Creates an Intune Win32 script application that writes registry keys to install and configure the Check by CyberDrain browser extension on managed devices for both Google Chrome and Microsoft Edge browsers. Uses a PowerShell detection script to enforce configuration drift — when settings change in CIPP the app is automatically redeployed.", + "executiveText": "Automatically deploys the Check by CyberDrain browser extension across all company devices with configurable security and branding settings, ensuring consistent security monitoring and compliance capabilities. This extension provides enhanced security features and monitoring tools that help protect against threats while maintaining user productivity.", "addedComponent": [ + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.showNotifications", + "label": "Show notifications", + "defaultValue": true + }, { "type": "switch", "name": "standards.DeployCheckChromeExtension.enableValidPageBadge", "label": "Enable valid page badge", - "defaultValue": true + "defaultValue": false }, { "type": "switch", @@ -5863,6 +5869,12 @@ "label": "Enable page blocking", "defaultValue": true }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.forceToolbarPin", + "label": "Force pin extension to toolbar", + "defaultValue": false + }, { "type": "switch", "name": "standards.DeployCheckChromeExtension.enableCippReporting", @@ -5874,13 +5886,14 @@ "name": "standards.DeployCheckChromeExtension.customRulesUrl", "label": "Custom Rules URL", "placeholder": "https://YOUR-CIPP-SERVER-URL/rules.json", + "helperText": "Enter the URL for custom rules if you have them. This should point to a JSON file with the same structure as the rules.json used for CIPP reporting.", "required": false }, { "type": "number", "name": "standards.DeployCheckChromeExtension.updateInterval", "label": "Update interval (hours)", - "defaultValue": 12 + "defaultValue": 24 }, { "type": "switch", @@ -5888,6 +5901,39 @@ "label": "Enable debug logging", "defaultValue": false }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enableGenericWebhook", + "label": "Enable generic webhook", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.webhookUrl", + "label": "Webhook URL", + "placeholder": "https://webhook.example.com/endpoint", + "required": false + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "name": "standards.DeployCheckChromeExtension.webhookEvents", + "label": "Webhook Events", + "placeholder": "e.g. pageBlocked, pageAllowed" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "freeSolo": true, + "name": "standards.DeployCheckChromeExtension.urlAllowlist", + "label": "URL Allowlist", + "placeholder": "e.g. https://example.com/*", + "helperText": "Enter URLs to allowlist in the extension. Press enter to add each URL. Wildcards are allowed. This should be used for sites that are being blocked by the extension but are known to be safe." + }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.companyName", @@ -5895,6 +5941,13 @@ "placeholder": "YOUR-COMPANY", "required": false }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.companyURL", + "label": "Company URL", + "placeholder": "https://yourcompany.com", + "required": false + }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.productName", @@ -5913,7 +5966,7 @@ "type": "textField", "name": "standards.DeployCheckChromeExtension.primaryColor", "label": "Primary Color", - "placeholder": "#0044CC", + "placeholder": "#F77F00", "required": false }, { @@ -5925,7 +5978,7 @@ }, { "name": "AssignTo", - "label": "Who should this policy be assigned to?", + "label": "Who should this app be assigned to?", "type": "radio", "options": [ { @@ -5957,11 +6010,11 @@ "label": "Enter the custom group name if you selected 'Assign to Custom Group'. Wildcards are allowed." } ], - "label": "Deploy Check Chrome Extension", + "label": "Deploy Check by CyberDrain Browser Extension", "impact": "Low Impact", "impactColour": "info", "addedDate": "2025-09-18", - "powershellEquivalent": "New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies'", + "powershellEquivalent": "Add-CIPPW32ScriptApplication", "recommendedBy": ["CIPP"] }, { diff --git a/src/layouts/config.js b/src/layouts/config.js index 4f8be5c348c7..bcd5bb786896 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -347,13 +347,18 @@ export const nativeMenuItems = [ }, { title: "Reports", - permissions: ["Tenant.DeviceCompliance.*"], + permissions: ["Tenant.DeviceCompliance.*", "Security.Defender.*"], items: [ { title: "Device Compliance", path: "/security/reports/list-device-compliance", permissions: ["Tenant.DeviceCompliance.*"], }, + { + title: "MDE Onboarding", + path: "/security/reports/mde-onboarding", + permissions: ["Security.Defender.*"], + }, ], }, { @@ -903,7 +908,6 @@ export const nativeMenuItems = [ path: "/cipp/scheduler", roles: ["editor", "admin", "superadmin"], permissions: ["CIPP.Scheduler.*"], - scope: "global", }, ], }, diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 3c8eb4c65dd2..4fe373bb2623 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -2,9 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import NextLink from "next/link"; import PropTypes from "prop-types"; import Bars3Icon from "@heroicons/react/24/outline/Bars3Icon"; +import MagnifyingGlassIcon from "@heroicons/react/24/outline/MagnifyingGlassIcon"; import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; import SunIcon from "@heroicons/react/24/outline/SunIcon"; import BookmarkIcon from "@mui/icons-material/Bookmark"; +import TravelExploreIcon from "@mui/icons-material/TravelExplore"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; @@ -17,6 +19,9 @@ import LockOpenIcon from "@mui/icons-material/LockOpen"; import { Box, Divider, + Dialog, + DialogContent, + DialogTitle, IconButton, Stack, SvgIcon, @@ -35,13 +40,12 @@ import { AccountPopover } from "./account-popover"; import { CippTenantSelector } from "../components/CippComponents/CippTenantSelector"; import { NotificationsPopover } from "./notifications-popover"; import { useDialog } from "../hooks/use-dialog"; -import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; -import { CippCentralSearch } from "../components/CippComponents/CippCentralSearch"; +import { CippUniversalSearchV2 } from "../components/CippCards/CippUniversalSearchV2"; const TOP_NAV_HEIGHT = 64; export const TopNav = (props) => { - const searchDialog = useDialog(); + const universalSearchDialog = useDialog(); const { onNavOpen } = props; const settings = useSettings(); const { bookmarks, setBookmarks } = useUserBookmarks(); @@ -64,6 +68,8 @@ export const TopNav = (props) => { const [animatingPair, setAnimatingPair] = useState(null); const [flashSort, setFlashSort] = useState(false); const [flashLock, setFlashLock] = useState(false); + const [universalSearchKey, setUniversalSearchKey] = useState(0); + const [universalSearchDefaultType, setUniversalSearchDefaultType] = useState("Users"); const itemRefs = useRef({}); const touchDragRef = useRef({ startIdx: null, overIdx: null }); const tenantSelectorRef = useRef(null); @@ -194,25 +200,34 @@ export const TopNav = (props) => { const popoverOpen = Boolean(anchorEl); const popoverId = popoverOpen ? "bookmark-popover" : undefined; - const openSearch = useCallback(() => { - searchDialog.handleOpen(); - }, [searchDialog.handleOpen]); + const openUniversalSearch = useCallback((defaultType = "Users") => { + setUniversalSearchDefaultType(defaultType); + universalSearchDialog.handleOpen(); + }, [universalSearchDialog.handleOpen]); + + const closeUniversalSearch = useCallback(() => { + universalSearchDialog.handleClose(); + setUniversalSearchKey((prev) => prev + 1); + }, [universalSearchDialog.handleClose]); useEffect(() => { const handleKeyDown = (event) => { if ((event.metaKey || event.ctrlKey) && event.altKey && event.key === "k") { event.preventDefault(); tenantSelectorRef.current?.focus(); + } else if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === "K") { + event.preventDefault(); + openUniversalSearch("Users"); } else if ((event.metaKey || event.ctrlKey) && event.key === "k") { event.preventDefault(); - openSearch(); + openUniversalSearch("Pages"); } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [openSearch]); + }, [openUniversalSearch]); return ( { { )} + {!mdDown && ( + openUniversalSearch("Users")} + title="Open Universal Search (Ctrl/Cmd+Shift+K)" + > + + + )} {!mdDown && ( @@ -277,11 +302,17 @@ export const TopNav = (props) => { )} - openSearch()}> - - - - + {!mdDown && ( + openUniversalSearch("Pages")} + title="Open Page Search (Ctrl/Cmd+K)" + > + + + + + )} {showPopoverBookmarks && ( <> @@ -570,7 +601,33 @@ export const TopNav = (props) => { )} - + + Universal Search + + + + + + { const actionSyncResults = ApiGetCall({ ...syncQuery, }); + const clearHIBPKey = ApiPostCall({ + relatedQueryKeys: ["Integrations"], + }); const handleIntegrationSync = () => { setSyncQuery({ url: "/api/ExecExtensionSync", @@ -191,6 +194,23 @@ const Page = () => { )} + {extension?.id === "HIBP" && ( + + + + )} {extension?.links && ( <> {extension.links.map((link, index) => ( @@ -208,6 +228,7 @@ const Page = () => { + diff --git a/src/pages/cipp/scheduler/index.js b/src/pages/cipp/scheduler/index.js index 73b2396f5171..446f7ca0593f 100644 --- a/src/pages/cipp/scheduler/index.js +++ b/src/pages/cipp/scheduler/index.js @@ -66,7 +66,6 @@ const Page = () => { } - tenantInTitle={false} title="Scheduled Tasks" apiUrl={ showHiddenJobs ? `/api/ListScheduledItems?ShowHidden=true` : `/api/ListScheduledItems` diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index b5816cadde66..d734f7eac437 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -2,11 +2,16 @@ import tabOptions from "./tabOptions"; import { TabbedLayout } from "../../../layouts/TabbedLayout"; import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; -import { Button, SvgIcon, Stack } from "@mui/material"; +import { Button, SvgIcon, Stack, Box } from "@mui/material"; import { TrashIcon } from "@heroicons/react/24/outline"; import { Add, RestartAlt } from "@mui/icons-material"; import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; import { useDialog } from "../../../hooks/use-dialog"; +import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; +import { CippFormCondition } from "../../../components/CippComponents/CippFormCondition"; +import M365LicensesDefault from "../../../data/M365Licenses.json"; +import M365LicensesAdditional from "../../../data/M365Licenses-additional.json"; +import { useMemo, useCallback } from "react"; const Page = () => { const pageTitle = "Excluded Licenses"; @@ -15,6 +20,34 @@ const Page = () => { const resetDialog = useDialog(); const simpleColumns = ["Product_Display_Name", "GUID"]; + const allLicenseOptions = useMemo(() => { + const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional]; + const uniqueLicenses = new Map(); + + allLicenses.forEach((license) => { + if (license.GUID && license.Product_Display_Name) { + if (!uniqueLicenses.has(license.GUID)) { + uniqueLicenses.set(license.GUID, { + label: license.Product_Display_Name, + value: license.GUID, + }); + } + } + }); + + const options = Array.from(uniqueLicenses.values()); + const nameCounts = {}; + options.forEach((opt) => { + nameCounts[opt.label] = (nameCounts[opt.label] || 0) + 1; + }); + + return options + .map((opt) => + nameCounts[opt.label] > 1 ? { ...opt, label: `${opt.label} (${opt.value})` } : opt + ) + .sort((a, b) => a.label.localeCompare(b.label)); + }, []); + const actions = [ { label: "Delete Exclusion", @@ -65,6 +98,21 @@ const Page = () => { actions: actions, }; + const addExclusionFormatter = useCallback((row, action, formData) => { + if (formData.advancedMode) { + return { + Action: "AddExclusion", + GUID: formData.GUID, + SKUName: formData.SKUName, + }; + } + return { + Action: "AddExclusion", + GUID: formData.selectedLicense?.value, + SKUName: formData.selectedLicense?.label, + }; + }, []); + return ( <> { + > + {({ formHook }) => ( + <> + + + + + + + + + + + + + + + + )} + { const [domainVisible, setDomainVisible] = useState(false); const organization = ApiGetCall({ - url: "/api/ListOrg", - queryKey: `${currentTenant}-ListOrg`, - data: { tenantFilter: currentTenant }, + url: "/api/ListGraphRequest", + queryKey: `${currentTenant}-ListGraphRequest-organization`, + data: { tenantFilter: currentTenant, Endpoint: "organization" }, }); + const organizationRecord = organization.data?.Results?.[0]; + const dashboard = ApiGetCall({ url: "/api/ListuserCounts", data: { tenantFilter: currentTenant }, @@ -68,12 +70,12 @@ const Page = () => { // Top bar data const tenantInfo = [ - { name: "Tenant Name", data: organization.data?.displayName }, + { name: "Tenant Name", data: organizationRecord?.displayName }, { name: "Tenant ID", data: ( <> - + ), }, @@ -83,7 +85,7 @@ const Page = () => { <> domain.isDefault === true)?.name + organizationRecord?.verifiedDomains?.find((domain) => domain.isDefault === true)?.name } type="chip" /> @@ -92,7 +94,7 @@ const Page = () => { }, { name: "AD Sync Enabled", - data: getCippFormatting(organization.data?.onPremisesSyncEnabled, "dirsync"), + data: getCippFormatting(organizationRecord?.onPremisesSyncEnabled, "dirsync"), }, ]; @@ -369,14 +371,14 @@ const Page = () => { showDivider={false} copyItems={true} isFetching={organization.isFetching} - propertyItems={organization.data?.verifiedDomains + propertyItems={organizationRecord?.verifiedDomains ?.slice(0, domainVisible ? undefined : 3) .map((domain, idx) => ({ label: "", value: domain.name, }))} actionButton={ - organization.data?.verifiedDomains?.length > 3 && ( + organizationRecord?.verifiedDomains?.length > 3 && ( @@ -417,7 +419,7 @@ const Page = () => { propertyItems={[ { label: "Services", - value: organization.data?.assignedPlans + value: organizationRecord?.assignedPlans ?.filter( (plan) => plan.capabilityStatus === "Enabled" && diff --git a/src/pages/dashboardv2/index.js b/src/pages/dashboardv2/index.js index fcb983b4e36d..d45b52afa29b 100644 --- a/src/pages/dashboardv2/index.js +++ b/src/pages/dashboardv2/index.js @@ -8,7 +8,6 @@ import { ApiGetCall } from "../../api/ApiCall.jsx"; import Portals from "../../data/portals"; import { BulkActionsMenu } from "../../components/bulk-actions-menu.js"; import { ExecutiveReportButton } from "../../components/ExecutiveReportButton.js"; -import { CippUniversalSearchV2 } from "../../components/CippCards/CippUniversalSearchV2.jsx"; import { TabbedLayout } from "../../layouts/TabbedLayout"; import { Layout as DashboardLayout } from "../../layouts/index.js"; import tabOptions from "./tabOptions"; @@ -87,11 +86,13 @@ const Page = () => { }, [reportIdValue]); const organization = ApiGetCall({ - url: "/api/ListOrg", - queryKey: `${currentTenant}-ListOrg`, - data: { tenantFilter: currentTenant }, + url: "/api/ListGraphRequest", + queryKey: `${currentTenant}-ListGraphRequest-organization`, + data: { tenantFilter: currentTenant, Endpoint: "organization" }, }); + const organizationRecord = organization.data?.Results?.[0]; + const testsApi = ApiGetCall({ url: "/api/ListTests", data: { tenantFilter: currentTenant, reportId: selectedReport }, @@ -112,7 +113,7 @@ const Page = () => { testsApi.isSuccess && testsApi.data?.TenantCounts ? { ExecutedAt: testsApi.data?.LatestReportTimeStamp || null, - TenantName: organization.data?.displayName || "", + TenantName: organizationRecord?.displayName || "", Domain: currentTenant || "", TestResultSummary: { IdentityPassed: testsApi.data.TestCounts?.Identity?.Passed || 0, @@ -208,13 +209,6 @@ const Page = () => { return ( - {/* Universal Search */} - - - - - - @@ -326,7 +320,7 @@ const Page = () => { {/* Column 1: Tenant Information */} - + {/* Column 2: Tenant Metrics - 2x3 Grid */} diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 49e5bc4b2816..87ef0d12901a 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -62,47 +62,59 @@ const Page = () => { }, ]; + // Builds a customDataformatter that handles both single-row and bulk (array) inputs. + const makeAssignFormatter = (getRowData) => (row, action, formData) => { + const formatRow = (singleRow) => { + const tenantFilterValue = + tenant === "AllTenants" && singleRow?.Tenant ? singleRow.Tenant : tenant; + return { + tenantFilter: tenantFilterValue, + ID: singleRow?.id, + AppType: getAppAssignmentSettingsType(singleRow?.["@odata.type"]), + AssignmentFilterName: formData?.assignmentFilter?.value || null, + AssignmentFilterType: formData?.assignmentFilter?.value + ? formData?.assignmentFilterType || "include" + : null, + ...getRowData(singleRow, formData), + }; + }; + return Array.isArray(row) ? row.map(formatRow) : formatRow(row); + }; + + const assignmentFields = [ + { + type: "radio", + name: "Intent", + label: "Assignment intent", + options: assignmentIntentOptions, + defaultValue: "Required", + validators: { required: "Select an assignment intent" }, + helperText: + "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", + }, + { + type: "radio", + name: "assignmentMode", + label: "Assignment mode", + options: assignmentModeOptions, + defaultValue: "replace", + helperText: + "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", + }, + ...getAssignmentFilterFields(), + ]; + const actions = [ { label: "Assign to All Users", type: "POST", url: "/api/ExecAssignApp", - fields: [ - { - type: "radio", - name: "Intent", - label: "Assignment intent", - options: assignmentIntentOptions, - defaultValue: "Required", - validators: { required: "Select an assignment intent" }, - helperText: - "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", - }, - { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", - options: assignmentModeOptions, - defaultValue: "replace", - helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", - }, - ...getAssignmentFilterFields(), - ], - customDataformatter: (row, action, formData) => { - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; - return { - tenantFilter: tenantFilterValue, - ID: row?.id, - AssignTo: "AllUsers", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", - AssignmentFilterName: formData?.assignmentFilter?.value || null, - AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" - : null, - }; - }, + fields: assignmentFields, + customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ + AssignTo: "AllUsers", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + })), confirmText: 'Are you sure you want to assign "[displayName]" to all users?', icon: , color: "info", @@ -111,42 +123,12 @@ const Page = () => { label: "Assign to All Devices", type: "POST", url: "/api/ExecAssignApp", - fields: [ - { - type: "radio", - name: "Intent", - label: "Assignment intent", - options: assignmentIntentOptions, - defaultValue: "Required", - validators: { required: "Select an assignment intent" }, - helperText: - "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", - }, - { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", - options: assignmentModeOptions, - defaultValue: "replace", - helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", - }, - ...getAssignmentFilterFields(), - ], - customDataformatter: (row, action, formData) => { - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; - return { - tenantFilter: tenantFilterValue, - ID: row?.id, - AssignTo: "AllDevices", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", - AssignmentFilterName: formData?.assignmentFilter?.value || null, - AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" - : null, - }; - }, + fields: assignmentFields, + customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ + AssignTo: "AllDevices", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + })), confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', icon: , color: "info", @@ -155,42 +137,12 @@ const Page = () => { label: "Assign Globally (All Users / All Devices)", type: "POST", url: "/api/ExecAssignApp", - fields: [ - { - type: "radio", - name: "Intent", - label: "Assignment intent", - options: assignmentIntentOptions, - defaultValue: "Required", - validators: { required: "Select an assignment intent" }, - helperText: - "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", - }, - { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", - options: assignmentModeOptions, - defaultValue: "replace", - helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", - }, - ...getAssignmentFilterFields(), - ], - customDataformatter: (row, action, formData) => { - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; - return { - tenantFilter: tenantFilterValue, - ID: row?.id, - AssignTo: "AllDevicesAndUsers", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", - AssignmentFilterName: formData?.assignmentFilter?.value || null, - AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" - : null, - }; - }, + fields: assignmentFields, + customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ + AssignTo: "AllDevicesAndUsers", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + })), confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', icon: , color: "info", @@ -252,23 +204,15 @@ const Page = () => { }, ...getAssignmentFilterFields(), ], - customDataformatter: (row, action, formData) => { + customDataformatter: makeAssignFormatter((_singleRow, formData) => { const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; return { - tenantFilter: tenantFilterValue, - ID: row?.id, GroupIds: selectedGroups.map((group) => group.value).filter(Boolean), GroupNames: selectedGroups.map((group) => group.label).filter(Boolean), Intent: formData?.assignmentIntent || "Required", AssignmentMode: formData?.assignmentMode || "replace", - AppType: getAppAssignmentSettingsType(row?.["@odata.type"]), - AssignmentFilterName: formData?.assignmentFilter?.value || null, - AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" - : null, }; - }, + }), }, { label: "Delete Application", diff --git a/src/pages/identity/administration/group-templates/edit.jsx b/src/pages/identity/administration/group-templates/edit.jsx index 5fd7f3417805..8c83b0461005 100644 --- a/src/pages/identity/administration/group-templates/edit.jsx +++ b/src/pages/identity/administration/group-templates/edit.jsx @@ -46,6 +46,7 @@ const Page = () => { allowExternal: templateData.allowExternal, tenantFilter: userSettingsDefaults.currentTenant, }); + formControl.trigger(); } } }, [template, formControl, userSettingsDefaults.currentTenant]); diff --git a/src/pages/identity/administration/jit-admin/add.jsx b/src/pages/identity/administration/jit-admin/add.jsx index 3e7f80d73559..16842bd313f6 100644 --- a/src/pages/identity/administration/jit-admin/add.jsx +++ b/src/pages/identity/administration/jit-admin/add.jsx @@ -29,6 +29,20 @@ const Page = () => { }); const watcher = useWatch({ control: formControl.control }); + const useTAP = useWatch({ control: formControl.control, name: "UseTAP" }); + + const tapPolicy = ApiGetCall({ + url: selectedTenant + ? `/api/ListGraphRequest` + : undefined, + data: { + Endpoint: "policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass", + tenantFilter: selectedTenant?.value, + }, + queryKey: selectedTenant ? `TAPPolicy-${selectedTenant.value}` : "TAPPolicy", + waiting: !!selectedTenant, + }); + const tapEnabled = tapPolicy.isSuccess && tapPolicy.data?.Results?.[0]?.state === "enabled"; const useRoles = useWatch({ control: formControl.control, name: "useRoles" }); const useGroups = useWatch({ control: formControl.control, name: "useGroups" }); @@ -473,6 +487,11 @@ const Page = () => { name="UseTAP" formControl={formControl} /> + {useTAP && tapPolicy.isSuccess && !tapEnabled && ( + + TAP is not enabled in this tenant. TAP generation will fail. + + )} { enableOOO: false, oooInternalMessage: null, oooExternalMessage: null, + oooCreateOOFEvent: false, + oooOOFEventSubject: "", + oooAutoDeclineFutureRequests: false, + oooDeclineEvents: false, + oooDeclineMeetingMessage: "", startDate: null, endDate: null, postExecution: [], diff --git a/src/pages/security/reports/mde-onboarding/index.js b/src/pages/security/reports/mde-onboarding/index.js new file mode 100644 index 000000000000..015653b411c1 --- /dev/null +++ b/src/pages/security/reports/mde-onboarding/index.js @@ -0,0 +1,235 @@ +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { useSettings } from "../../../../hooks/use-settings"; +import { + Card, + CardContent, + CardHeader, + Chip, + Container, + Stack, + Typography, + CircularProgress, + Button, + SvgIcon, + IconButton, + Tooltip, +} from "@mui/material"; +import { Sync, Info, OpenInNew } from "@mui/icons-material"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import { CippHead } from "../../../../components/CippComponents/CippHead"; +import { useDialog } from "../../../../hooks/use-dialog"; +import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog"; +import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; +import { useState } from "react"; + +const statusColors = { + enabled: "success", + available: "success", + unavailable: "error", + unresponsive: "warning", + notSetUp: "default", + error: "error", +}; + +const statusLabels = { + enabled: "Enabled", + available: "Available", + unavailable: "Unavailable", + unresponsive: "Unresponsive", + notSetUp: "Not Set Up", + error: "Error", +}; + +const SingleTenantView = ({ tenant }) => { + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + + const tenantList = ApiGetCall({ + url: "/api/listTenants", + queryKey: "TenantSelector", + }); + + const tenantId = tenantList.data?.find( + (t) => t.defaultDomainName === tenant + )?.customerId; + + const { data, isFetching } = ApiGetCall({ + url: "/api/ListMDEOnboarding", + queryKey: `MDEOnboarding-${tenant}`, + data: { tenantFilter: tenant, UseReportDB: true }, + waiting: true, + }); + + const item = Array.isArray(data) ? data[0] : data; + const status = item?.partnerState || "Unknown"; + + return ( + <> + + + + + + + + + + + + + } + /> + + {isFetching ? ( + + ) : ( + + + Status: + + + {item?.CacheTimestamp && ( + + Last synced: {new Date(item.CacheTimestamp).toLocaleString()} + + )} + {item?.error && ( + + {item.error} + + )} + {tenantId && status !== "enabled" && status !== "available" && ( + + )} + + )} + + + + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result.Metadata.QueueId); + } + }, + }} + /> + + ); +}; + +const Page = () => { + const currentTenant = useSettings().currentTenant; + const isAllTenants = currentTenant === "AllTenants"; + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + + if (!isAllTenants) { + return ; + } + + const pageActions = [ + + + + + + + + + , + ]; + + return ( + <> + + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result.Metadata.QueueId); + } + }, + }} + /> + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; \ No newline at end of file diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index 577134d1fcc6..62086e158696 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -184,6 +184,7 @@ const AlertWizard = () => { recurrence: recurrenceOption, postExecution: postExecutionValue, startDateTime: startDateTimeForForm, + CustomSubject: alert.RawAlert.CustomSubject || "", AlertComment: alert.RawAlert.AlertComment || "", }; if (usedCommand?.requiresInput && alert.RawAlert.Parameters) { @@ -256,6 +257,7 @@ const AlertWizard = () => { Actions: alert.RawAlert.Actions, logbook: foundLogbook, AlertComment: alert.RawAlert.AlertComment || "", + CustomSubject: alert.RawAlert.CustomSubject || "", conditions: [], // Include empty array to register field structure }; // Reset first without spawning rows to avoid rendering empty operator fields @@ -477,7 +479,9 @@ const AlertWizard = () => { RowKey: router.query.clone ? undefined : router.query.id ? router.query.id : undefined, tenantFilter: values.tenantFilter, excludedTenants: values.excludedTenants, - Name: `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.command.label}`, + Name: values.CustomSubject + ? `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.CustomSubject}` + : `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.command.label}`, Command: { value: `Get-CIPPAlert${values.command.value.name}` }, Parameters: getInputParams(), ScheduledTime: Math.floor(new Date().getTime() / 1000) + 60, @@ -485,6 +489,7 @@ const AlertWizard = () => { Recurrence: values.recurrence, PostExecution: values.postExecution, AlertComment: values.AlertComment, + CustomSubject: values.CustomSubject, }; apiRequest.mutate( { url: "/api/AddScheduledItem?hidden=true", data: postObject }, @@ -600,19 +605,7 @@ const AlertWizard = () => { - } - > - Save Alert - - } - sx={{ mb: 3 }} - > + { ))} + + - - - - - - - - + + } + > + Save Alert + + } + > + + + + + + + + + + + + + + @@ -897,19 +918,7 @@ const AlertWizard = () => { - } - > - Save Alert - - } - > + { ))} - - - - - - - - - + + + + + + } + > + Save Alert + + } + > + + + + + + + + + + + + diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js index e2ad9865383b..612200a44560 100644 --- a/src/pages/tenant/manage/drift.js +++ b/src/pages/tenant/manage/drift.js @@ -2126,6 +2126,15 @@ const ManageDriftPage = () => { open={Boolean(anchorEl[`denied-${item.id}`])} onClose={() => handleMenuClose(`denied-${item.id}`)} > + { + handleDeviationAction("deny-remediate", item); + handleMenuClose(`denied-${item.id}`); + }} + > + + Deny - Remediate to align with template + { handleDeviationAction("accept", item); diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 08979dd2ed50..8eb3a3471455 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -56,15 +56,38 @@ const Page = () => { multiple: false, creatable: true, }, + { + label: "Username Space Handling", + name: "usernameSpaceHandling", + type: "autoComplete", + options: [ + { label: "Keep spaces", value: "keep" }, + { label: "Remove spaces", value: "remove" }, + { label: "Replace spaces", value: "replace" }, + ], + helperText: "How spaces in the generated username should be handled.", + multiple: false, + creatable: false, + }, + { + label: "Username Space Replacement", + name: "usernameSpaceReplacement", + type: "textField", + helperText: "Used when space handling is set to Replace spaces (example: _ or .).", + }, { label: "Primary Domain", name: "primDomain", type: "autoComplete", api: { - url: "/api/ListDomains", + url: "/api/ListGraphRequest", + dataKey: "Results", + data: { + Endpoint: "domains", + }, labelField: "id", valueField: "id", - queryKey: "ListDomains", + queryKey: "ListGraphRequest-domains", }, multiple: false, creatable: false, @@ -184,6 +207,8 @@ const Page = () => { "defaultForTenant", "displayName", "usernameFormat", + "usernameSpaceHandling", + "usernameSpaceReplacement", "primDomain", "usageLocation", "licenses", @@ -222,6 +247,7 @@ const Page = () => { "defaultForTenant", "displayName", "usernameFormat", + "usernameSpaceHandling", "usageLocation", "department", ]} diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 79139e2b2347..5e17c3735042 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -10,7 +10,7 @@ import { PrecisionManufacturing, BarChart, } from "@mui/icons-material"; -import { Chip, Link, SvgIcon } from "@mui/material"; +import { Chip, Link, SvgIcon, Tooltip } from "@mui/material"; import { alpha } from "@mui/material/styles"; import { Box } from "@mui/system"; import { CippCopyToClipBoard } from "../components/CippComponents/CippCopyToClipboard"; @@ -220,7 +220,11 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr (hardwareHashFields.includes(cellName) || cellNameLower.includes("hardware")) ) { if (data.length > 15) { - return isText ? data : `${data.substring(0, 15)}...`; + return isText ? data : ( + + {data.substring(0, 15)}... + + ); } return isText ? data : data; } @@ -229,7 +233,11 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr const messageFields = ["Message"]; if (messageFields.includes(cellName)) { if (typeof data === "string" && data.length > 120) { - return isText ? data : `${data.substring(0, 120)}...`; + return isText ? data : ( + + {data.substring(0, 120)}... + + ); } return isText ? data : data; } diff --git a/yarn.lock b/yarn.lock index 388c3f43e078..d1493baeab00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2487,6 +2487,11 @@ resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.3.tgz#85f1d1d17569b28b8db45e16e996407a56b0ab04" integrity sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw== +"@types/react-dom@^19.2.3": + version "19.2.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== + "@types/react-redux@^7.1.20": version "7.1.34" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.34.tgz#83613e1957c481521e6776beeac4fd506d11bd0e" @@ -2502,7 +2507,7 @@ resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@*": +"@types/react@*", "@types/react@^19.2.14": version "19.2.14" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad" integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==