From 0ec9e667ce78787a5140364a939b37ef0444b047 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 27 Feb 2026 16:17:13 -0500 Subject: [PATCH 01/64] feat: Add license backfill and caching utilities Introduce a license backfill system and integrate it into UI components. Adds a global LicenseBackfillManager (cipp-license-backfill-manager) that batches/debounces missing skuId requests and fetches licenses via /api/ExecLicenseSearch, plus a localStorage-backed cache (cipp-license-cache) to store dynamic license display names. Exposes a useLicenseBackfill hook to trigger component re-renders when backfill completes. get-cipp-license-translation now checks static JSON, then the dynamic cache, falls back to skuPartNumber/skuId, and queues unknown skuIds for backfill. Components (CippUserInfoCard, CippDataTable) updated to use the hook and include an updateTrigger to refresh displayed license info when backfill finishes. --- src/components/CippCards/CippUserInfoCard.jsx | 5 + src/components/CippTable/CippDataTable.js | 7 +- src/hooks/use-license-backfill.js | 42 +++++ src/utils/cipp-license-backfill-manager.js | 160 ++++++++++++++++++ src/utils/cipp-license-cache.js | 109 ++++++++++++ src/utils/get-cipp-license-translation.js | 32 +++- 6 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 src/hooks/use-license-backfill.js create mode 100644 src/utils/cipp-license-backfill-manager.js create mode 100644 src/utils/cipp-license-cache.js diff --git a/src/components/CippCards/CippUserInfoCard.jsx b/src/components/CippCards/CippUserInfoCard.jsx index 43e4d6d26cb2..6814a6e32790 100644 --- a/src/components/CippCards/CippUserInfoCard.jsx +++ b/src/components/CippCards/CippUserInfoCard.jsx @@ -18,6 +18,7 @@ import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { Stack, Grid, Box } from "@mui/system"; import { useState, useRef, useCallback } from "react"; import { ApiPostCall } from "../../api/ApiCall"; +import { useLicenseBackfill } from "../../hooks/use-license-backfill"; export const CippUserInfoCard = (props) => { const { user, tenant, isFetching = false, ...other } = props; @@ -25,6 +26,9 @@ export const CippUserInfoCard = (props) => { const [uploadError, setUploadError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const fileInputRef = useRef(null); + + // Hook to trigger re-render when license backfill completes + const { updateTrigger } = useLicenseBackfill(); // API mutations const setPhotoMutation = ApiPostCall({ urlFromData: true }); @@ -280,6 +284,7 @@ export const CippUserInfoCard = (props) => { diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 4baad9dca6a9..759b9e5ee353 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -25,6 +25,7 @@ import { getCippError } from "../../utils/get-cipp-error"; import { Box } from "@mui/system"; import { useSettings } from "../../hooks/use-settings"; import { isEqual } from "lodash"; // Import lodash for deep comparison +import { useLicenseBackfill } from "../../hooks/use-license-backfill"; // Resolve dot-delimited property paths against arbitrary data objects. const getNestedValue = (source, path) => { @@ -146,6 +147,9 @@ export const CippDataTable = (props) => { const waitingBool = api?.url ? true : false; const settings = useSettings(); + + // Hook to trigger re-render when license backfill completes + const { updateTrigger } = useLicenseBackfill(); const getRequestData = ApiGetCallWithPagination({ url: api.url, @@ -311,8 +315,9 @@ export const CippDataTable = (props) => { ), ); //create memoized version of usedColumns, and usedData + // Include updateTrigger in data to force re-render when license backfill completes const memoizedColumns = useMemo(() => usedColumns, [usedColumns]); - const memoizedData = useMemo(() => usedData, [usedData]); + const memoizedData = useMemo(() => usedData, [usedData, updateTrigger]); const handleActionDisabled = (row, action) => { if (action?.condition) { diff --git a/src/hooks/use-license-backfill.js b/src/hooks/use-license-backfill.js new file mode 100644 index 000000000000..97b52ea6315c --- /dev/null +++ b/src/hooks/use-license-backfill.js @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import licenseBackfillManager from "../utils/cipp-license-backfill-manager"; + +/** + * Hook to trigger re-render when license backfill completes + * Use this in components that display licenses to automatically update + * when missing licenses are fetched from the API + * + * @returns {Object} Object containing backfill status + */ +export const useLicenseBackfill = () => { + const [updateTrigger, setUpdateTrigger] = useState(0); + const [status, setStatus] = useState(licenseBackfillManager.getStatus()); + + useEffect(() => { + // Subscribe to backfill completion events + const unsubscribe = licenseBackfillManager.addCallback(() => { + // Trigger re-render by updating state + setUpdateTrigger((prev) => prev + 1); + setStatus(licenseBackfillManager.getStatus()); + }); + + // Update status periodically while backfilling + const interval = setInterval(() => { + const currentStatus = licenseBackfillManager.getStatus(); + if (currentStatus.isBackfilling !== status.isBackfilling || + currentStatus.pendingCount !== status.pendingCount) { + setStatus(currentStatus); + } + }, 200); + + return () => { + unsubscribe(); + clearInterval(interval); + }; + }, [status.isBackfilling, status.pendingCount]); + + return { + ...status, + updateTrigger, // Can be used as a key to force re-render if needed + }; +}; diff --git a/src/utils/cipp-license-backfill-manager.js b/src/utils/cipp-license-backfill-manager.js new file mode 100644 index 000000000000..8df50b94d83b --- /dev/null +++ b/src/utils/cipp-license-backfill-manager.js @@ -0,0 +1,160 @@ +/** + * Global license backfill manager + * Tracks missing licenses and triggers batch API calls to fetch them + */ + +import { getMissingFromCache, addLicensesToCache } from "./cipp-license-cache"; + +class LicenseBackfillManager { + constructor() { + this.pendingSkuIds = new Set(); + this.isBackfilling = false; + this.backfillTimeout = null; + this.callbacks = new Set(); + this.BATCH_DELAY = 500; // Wait 500ms to batch multiple requests + } + + /** + * Add a callback to be notified when backfill completes + */ + addCallback(callback) { + this.callbacks.add(callback); + return () => this.callbacks.delete(callback); + } + + /** + * Notify all callbacks + */ + notifyCallbacks() { + this.callbacks.forEach((callback) => { + try { + callback(); + } catch (error) { + console.error("Error in backfill callback:", error); + } + }); + } + + /** + * Add missing skuIds to the queue + */ + addMissingSkuIds(skuIds) { + if (!Array.isArray(skuIds)) return; + + let added = false; + skuIds.forEach((skuId) => { + if (skuId && !this.pendingSkuIds.has(skuId)) { + this.pendingSkuIds.add(skuId); + added = true; + } + }); + + if (added && !this.isBackfilling) { + this.scheduleBatchBackfill(); + } + } + + /** + * Schedule a batch backfill with debouncing + */ + scheduleBatchBackfill() { + // Clear existing timeout to debounce + if (this.backfillTimeout) { + clearTimeout(this.backfillTimeout); + } + + // Schedule new backfill + this.backfillTimeout = setTimeout(() => { + this.executeBatchBackfill(); + }, this.BATCH_DELAY); + } + + /** + * Execute the batch backfill + */ + async executeBatchBackfill() { + if (this.isBackfilling || this.pendingSkuIds.size === 0) { + return; + } + + // Get all pending skuIds + const skuIdsToFetch = Array.from(this.pendingSkuIds); + this.pendingSkuIds.clear(); + this.isBackfilling = true; + + try { + // Import axios dynamically to avoid circular dependencies + const axios = (await import("axios")).default; + const { buildVersionedHeaders } = await import("./cippVersion"); + + console.log(`[License Backfill] Fetching ${skuIdsToFetch.length} licenses...`); + + const response = await axios.post( + "/api/ExecLicenseSearch", + { skuIds: skuIdsToFetch }, + { headers: await buildVersionedHeaders() } + ); + + if (response.data && Array.isArray(response.data)) { + console.log(`[License Backfill] Received ${response.data.length} licenses`); + addLicensesToCache(response.data); + + // Notify all callbacks that backfill completed + this.notifyCallbacks(); + } + } catch (error) { + console.error("[License Backfill] Error fetching licenses:", error); + + // Re-add failed skuIds back to pending if we want to retry + // Commenting this out to avoid infinite retry loops + // skuIdsToFetch.forEach(skuId => this.pendingSkuIds.add(skuId)); + } finally { + this.isBackfilling = false; + + // If more skuIds were added during backfill, schedule another batch + if (this.pendingSkuIds.size > 0) { + this.scheduleBatchBackfill(); + } + } + } + + /** + * Check skuIds and add missing ones to backfill queue + */ + checkAndQueueMissing(skuIds) { + const missing = getMissingFromCache(skuIds); + if (missing.length > 0) { + this.addMissingSkuIds(missing); + return true; + } + return false; + } + + /** + * Get current backfill status + */ + getStatus() { + return { + isBackfilling: this.isBackfilling, + pendingCount: this.pendingSkuIds.size, + }; + } + + /** + * Clear all pending requests (useful for cleanup/testing) + */ + clear() { + if (this.backfillTimeout) { + clearTimeout(this.backfillTimeout); + this.backfillTimeout = null; + } + this.pendingSkuIds.clear(); + this.isBackfilling = false; + this.callbacks.clear(); + } +} + +// Global singleton instance +const licenseBackfillManager = new LicenseBackfillManager(); + +export default licenseBackfillManager; diff --git a/src/utils/cipp-license-cache.js b/src/utils/cipp-license-cache.js new file mode 100644 index 000000000000..089a09a7d4ef --- /dev/null +++ b/src/utils/cipp-license-cache.js @@ -0,0 +1,109 @@ +/** + * License cache manager for dynamically loaded licenses + * Uses localStorage to permanently cache licenses fetched from the API + * Cache only grows (appends missing licenses) and never expires + */ + +const CACHE_KEY = "cipp_dynamic_licenses"; +const CACHE_VERSION = "1.0"; + +/** + * Get the license cache from localStorage + * @returns {Object} Cache object with version, timestamp, and licenses map + */ +const getCache = () => { + try { + const cached = localStorage.getItem(CACHE_KEY); + if (!cached) { + return { version: CACHE_VERSION, timestamp: Date.now(), licenses: {} }; + } + + const parsed = JSON.parse(cached); + + // Check cache version - clear if outdated + if (parsed.version !== CACHE_VERSION) { + localStorage.removeItem(CACHE_KEY); + return { version: CACHE_VERSION, timestamp: Date.now(), licenses: {} }; + } + + return parsed; + } catch (error) { + console.error("Error reading license cache:", error); + return { version: CACHE_VERSION, timestamp: Date.now(), licenses: {} }; + } +}; + +/** + * Save the license cache to localStorage + * @param {Object} cache - Cache object to save + */ +const saveCache = (cache) => { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); + } catch (error) { + console.error("Error saving license cache:", error); + } +}; + +/** + * Get a license from the cache by skuId + * @param {string} skuId - The license skuId (GUID) + * @returns {string|null} The display name if found, null otherwise + */ +export const getCachedLicense = (skuId) => { + if (!skuId) return null; + + const cache = getCache(); + return cache.licenses[skuId.toLowerCase()] || null; +}; + +/** + * Add licenses to the cache + * @param {Array} licenses - Array of license objects with skuId and displayName + */ +export const addLicensesToCache = (licenses) => { + if (!Array.isArray(licenses) || licenses.length === 0) return; + + const cache = getCache(); + + licenses.forEach((license) => { + if (license.skuId && license.displayName) { + cache.licenses[license.skuId.toLowerCase()] = license.displayName; + } + }); + + cache.timestamp = Date.now(); + saveCache(cache); +}; + +/** + * Check if licenses exist in cache + * @param {Array} skuIds - Array of skuIds to check + * @returns {Array} Array of skuIds that are NOT in cache + */ +export const getMissingFromCache = (skuIds) => { + if (!Array.isArray(skuIds) || skuIds.length === 0) return []; + + const cache = getCache(); + return skuIds.filter((skuId) => !cache.licenses[skuId.toLowerCase()]); +}; + +/** + * Clear the entire license cache + */ +export const clearLicenseCache = () => { + try { + localStorage.removeItem(CACHE_KEY); + } catch (error) { + console.error("Error clearing license cache:", error); + } +}; + +/** + * Get all cached licenses + * @returns {Object} Map of skuId -> displayName + */ +export const getAllCachedLicenses = () => { + const cache = getCache(); + return cache.licenses; +}; diff --git a/src/utils/get-cipp-license-translation.js b/src/utils/get-cipp-license-translation.js index 4a85312eb95b..0397585d927a 100644 --- a/src/utils/get-cipp-license-translation.js +++ b/src/utils/get-cipp-license-translation.js @@ -1,10 +1,13 @@ import M365LicensesDefault from "../data/M365Licenses.json"; import M365LicensesAdditional from "../data/M365Licenses-additional.json"; +import { getCachedLicense } from "./cipp-license-cache"; +import licenseBackfillManager from "./cipp-license-backfill-manager"; export const getCippLicenseTranslation = (licenseArray) => { //combine M365LicensesDefault and M365LicensesAdditional to one array const M365Licenses = [...M365LicensesDefault, ...M365LicensesAdditional]; let licenses = []; + let missingSkuIds = []; if (Array.isArray(licenseArray) && typeof licenseArray[0] === "string") { return licenseArray; @@ -20,22 +23,47 @@ export const getCippLicenseTranslation = (licenseArray) => { licenseArray?.forEach((licenseAssignment) => { let found = false; + + // First, check static JSON files for (let x = 0; x < M365Licenses.length; x++) { if (licenseAssignment.skuId === M365Licenses[x].GUID) { licenses.push( M365Licenses[x].Product_Display_Name ? M365Licenses[x].Product_Display_Name - : licenseAssignment.skuPartNumber + : licenseAssignment.skuPartNumber, ); found = true; break; } } + + // Second, check dynamic cache + if (!found && licenseAssignment.skuId) { + const cachedName = getCachedLicense(licenseAssignment.skuId); + if (cachedName) { + licenses.push(cachedName); + found = true; + } + } + + // Finally, fall back to skuPartNumber, then skuId, then "Unknown License" if (!found) { - licenses.push(licenseAssignment.skuPartNumber); + const fallbackName = + licenseAssignment.skuPartNumber || licenseAssignment.skuId || "Unknown License"; + licenses.push(fallbackName); + + // Queue this skuId for backfill if we have it + if (licenseAssignment.skuId) { + missingSkuIds.push(licenseAssignment.skuId); + } } }); + // Trigger backfill for missing licenses + if (missingSkuIds.length > 0) { + licenseBackfillManager.addMissingSkuIds(missingSkuIds); + } + if (!licenses || licenses.length === 0) { return ["No Licenses Assigned"]; } From 0a683e693d7b6b6bb5bfa81ce1c8c7542643430e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 28 Feb 2026 00:17:11 -0500 Subject: [PATCH 02/64] feat: Add BitLocker key search page and component Introduce a BitLocker Key Search feature: adds a new CippBitlockerKeySearch React component (src/components/...) that queries /api/ExecBitlockerSearch using ApiGetCall and react-hook-form, renders search by Key ID or Device ID, and displays key and device details with copy-to-clipboard support and proper loading/error states. Adds a page at /tenant/tools/bitlocker-search (src/pages/...) and registers the menu entry in layouts config with the Endpoint.Device.Read permission so the tool appears under Tenant Tools. --- .../CippComponents/CippBitlockerKeySearch.jsx | 319 ++++++++++++++++++ src/layouts/config.js | 6 +- .../tenant/tools/bitlocker-search/index.js | 21 ++ 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 src/components/CippComponents/CippBitlockerKeySearch.jsx create mode 100644 src/pages/tenant/tools/bitlocker-search/index.js diff --git a/src/components/CippComponents/CippBitlockerKeySearch.jsx b/src/components/CippComponents/CippBitlockerKeySearch.jsx new file mode 100644 index 000000000000..c0af1f7723d6 --- /dev/null +++ b/src/components/CippComponents/CippBitlockerKeySearch.jsx @@ -0,0 +1,319 @@ +import React, { useState } from "react"; +import { + Box, + Button, + Typography, + Skeleton, + Grid, + Paper, + Divider, + Chip, + Alert, + ButtonGroup, +} from "@mui/material"; +import { Search, VpnKey, Computer, CheckCircle, Cancel, Info } from "@mui/icons-material"; +import { useForm, useWatch } from "react-hook-form"; +import CippButtonCard from "../CippCards/CippButtonCard"; +import { ApiGetCall } from "../../api/ApiCall"; +import { CippCopyToClipBoard } from "./CippCopyToClipboard"; +import CippFormComponent from "./CippFormComponent"; + +const getVolumeTypeLabel = (volumeType) => { + const types = { + 0: "Operating System Volume", + 1: "Fixed Data Volume", + 2: "Removable Data Volume", + 3: "Unknown", + }; + return types[volumeType] || `Type ${volumeType}`; +}; + +export const CippBitlockerKeySearch = () => { + const formControl = useForm({ + mode: "onBlur", + defaultValues: { + searchType: "keyId", + }, + }); + const searchTerm = useWatch({ control: formControl.control, name: "searchTerm" }); + const searchType = useWatch({ control: formControl.control, name: "searchType" }) || "keyId"; + + const getBitlockerKeys = ApiGetCall({ + url: "/api/ExecBitlockerSearch", + data: { [searchType]: searchTerm }, + queryKey: `bitlocker-${searchType}-${searchTerm}`, + waiting: false, + }); + + const handleSearch = (e) => { + e.preventDefault(); + if (searchTerm && !getBitlockerKeys.isFetching) { + getBitlockerKeys.refetch(); + } + }; + + const results = getBitlockerKeys.data?.Results || []; + + const searchTypeOptions = [ + { label: "Key ID", value: "keyId" }, + { label: "Device ID", value: "deviceId" }, + ]; + + return ( + + + {/* Search Section */} + + + + + + + + + + + + + {/* Results Section */} + {getBitlockerKeys.isFetching ? ( + + + + Searching... + + + + ) : getBitlockerKeys.isSuccess ? ( + <> + + + + + {results.length === 0 ? ( + + }> + No BitLocker keys found matching your search criteria. + + + ) : ( + + + Found {results.length} BitLocker Key{results.length !== 1 ? "s" : ""} + + + {results.map((result, index) => ( + + + {/* BitLocker Key Information */} + + + + BitLocker Key Information + + + + + + Key ID + + + + {result.keyId || "N/A"} + + {result.keyId && } + + + + + + Volume Type + + + + + + + Created + + + {result.createdDateTime + ? new Date(result.createdDateTime).toLocaleString() + : "N/A"} + + + + + + Tenant + + {result.tenant || "N/A"} + + + {/* Device Information */} + {result.deviceFound && ( + <> + + + + + Device Information + + + + + + Device Name + + {result.deviceName || "N/A"} + + + + + Device ID + + + + {result.deviceId || "N/A"} + + {result.deviceId && } + + + + + + Operating System + + + {result.operatingSystem || "N/A"} + {result.osVersion && ` (${result.osVersion})`} + + + + + + Account Status + + + ) : ( + + ) + } + label={result.accountEnabled ? "Enabled" : "Disabled"} + size="small" + color={result.accountEnabled ? "success" : "default"} + /> + + + + + Trust Type + + {result.trustType || "N/A"} + + + + + Last Sign In + + + {result.lastSignIn + ? new Date(result.lastSignIn).toLocaleString() + : "N/A"} + + + + )} + + {!result.deviceFound && ( + + }> + Device information not found in cache. The device may have been deleted + or not yet synced. + + + )} + + + ))} + + )} + + ) : getBitlockerKeys.isError ? ( + + + + Error searching for BitLocker keys: {getBitlockerKeys.error?.message} + + + ) : null} + + + ); +}; + +export default CippBitlockerKeySearch; diff --git a/src/layouts/config.js b/src/layouts/config.js index 3bfc34eeb508..14020757c07f 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -801,7 +801,6 @@ export const nativeMenuItems = [ path: "/tenant/tools/tenantlookup", permissions: ["Tenant.Administration.*"], }, - { title: "IP Database", path: "/tenant/tools/geoiplookup", @@ -813,6 +812,11 @@ export const nativeMenuItems = [ path: "/tenant/tools/individual-domains", permissions: ["Tenant.DomainAnalyser.*"], }, + { + title: "BitLocker Key Search", + path: "/tenant/tools/bitlocker-search", + permissions: ["Endpoint.Device.Read"], + }, ], }, { diff --git a/src/pages/tenant/tools/bitlocker-search/index.js b/src/pages/tenant/tools/bitlocker-search/index.js new file mode 100644 index 000000000000..d445b0b0632e --- /dev/null +++ b/src/pages/tenant/tools/bitlocker-search/index.js @@ -0,0 +1,21 @@ +import { Box, Container } from "@mui/material"; +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import CippBitlockerKeySearch from "../../../../components/CippComponents/CippBitlockerKeySearch"; + +const Page = () => { + return ( + + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; From 64471a1bd4f39d350b13c04db768a40d4421ecc4 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:23:15 +0100 Subject: [PATCH 03/64] fix: prevent HTML escaping of URLs in action links getNestedValue was HTML-encoding values used in window.open(), converting & to & in URLs. This consent URLs with query parameters, causing Microsoft OAuth to reject them with AADSTS900144. --- src/components/CippComponents/CippApiDialog.jsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index be3ce1cb2166..4ba2060a9410 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -269,10 +269,14 @@ export const CippApiDialog = (props) => { return div.innerHTML; }; - const getNestedValue = (obj, path) => { - const value = path + const getRawNestedValue = (obj, path) => { + return path .split(".") .reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj); + }; + + const getNestedValue = (obj, path) => { + const value = getRawNestedValue(obj, path); return typeof value === "string" ? escapeHtml(value) : value; }; @@ -288,7 +292,7 @@ export const CippApiDialog = (props) => { linkOpenedRef.current = true; const linkWithData = api.link.replace( /\[([^\]]+)\]/g, - (_, key) => getNestedValue(row, key) || `[${key}]`, + (_, key) => getRawNestedValue(row, key) || `[${key}]`, ); if (linkWithData.startsWith("/") && !api?.external) { router.push(linkWithData, undefined, { shallow: true }); From b68dc3b9d6b84b38f610e4292c52eb74265098f5 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:23:04 +0800 Subject: [PATCH 04/64] 12 Hour Update --- src/components/CippStandards/CippStandardsSideBar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CippStandards/CippStandardsSideBar.jsx b/src/components/CippStandards/CippStandardsSideBar.jsx index 16307ece2ff0..ab4c4513ab2d 100644 --- a/src/components/CippStandards/CippStandardsSideBar.jsx +++ b/src/components/CippStandards/CippStandardsSideBar.jsx @@ -519,7 +519,7 @@ const CippStandardsSideBar = ({ ? "This template will automatically every 12 hours to detect drift. Are you sure you want to apply this Drift Template?" : watchForm.runManually ? "Are you sure you want to apply this standard? This template has been set to never run on a schedule. After saving the template you will have to run it manually." - : "Are you sure you want to apply this standard? This will apply the template and run every 4 hours.", + : "Are you sure you want to apply this standard? This will apply the template and run every 12 hours.", url: "/api/AddStandardsTemplate", type: "POST", replacementBehaviour: "removeNulls", From 25d3417407129acc597e3a0b3dbb50b4852fc894 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 28 Feb 2026 14:34:13 -0500 Subject: [PATCH 05/64] Add BitLocker recovery key retrieval UI Add client-side support to retrieve and display BitLocker recovery keys per search result. Introduces ApiPostCall usage (ExecGetRecoveryKey), recoveryKeys and loadingKeys state, and handleRetrieveKey to fetch and store keys. UI changes add a "Retrieve Key" button with a CircularProgress indicator and Key icon, display retrieved key in monospace with a copy-to-clipboard action, and disable the button while loading or when identifiers are missing. Also import updates for icons and API call helpers; errors are logged to console on failure. Removed inline copy buttons for keyId/deviceId in the results. --- .../CippComponents/CippBitlockerKeySearch.jsx | 73 ++++++++++++++++++- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/src/components/CippComponents/CippBitlockerKeySearch.jsx b/src/components/CippComponents/CippBitlockerKeySearch.jsx index c0af1f7723d6..77ff210146de 100644 --- a/src/components/CippComponents/CippBitlockerKeySearch.jsx +++ b/src/components/CippComponents/CippBitlockerKeySearch.jsx @@ -10,13 +10,15 @@ import { Chip, Alert, ButtonGroup, + CircularProgress, } from "@mui/material"; -import { Search, VpnKey, Computer, CheckCircle, Cancel, Info } from "@mui/icons-material"; +import { Search, VpnKey, Computer, CheckCircle, Cancel, Info, Key } from "@mui/icons-material"; import { useForm, useWatch } from "react-hook-form"; import CippButtonCard from "../CippCards/CippButtonCard"; -import { ApiGetCall } from "../../api/ApiCall"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { CippCopyToClipBoard } from "./CippCopyToClipboard"; import CippFormComponent from "./CippFormComponent"; +import { useSettings } from "../../hooks/use-settings"; const getVolumeTypeLabel = (volumeType) => { const types = { @@ -38,6 +40,36 @@ export const CippBitlockerKeySearch = () => { const searchTerm = useWatch({ control: formControl.control, name: "searchTerm" }); const searchType = useWatch({ control: formControl.control, name: "searchType" }) || "keyId"; + // State to store retrieved recovery keys by keyId + const [recoveryKeys, setRecoveryKeys] = useState({}); + const [loadingKeys, setLoadingKeys] = useState({}); + + const retrieveKeyMutation = ApiPostCall({}); + + const handleRetrieveKey = async (keyId, deviceId, tenant) => { + setLoadingKeys((prev) => ({ ...prev, [keyId]: true })); + + try { + const response = await retrieveKeyMutation.mutateAsync({ + url: "/api/ExecGetRecoveryKey", + data: { + GUID: deviceId, + RecoveryKeyType: "BitLocker", + tenantFilter: tenant, + }, + }); + + // Extract the key from the response + if (response?.data?.Results?.copyField) { + setRecoveryKeys((prev) => ({ ...prev, [keyId]: response.data.Results.copyField })); + } + } catch (error) { + console.error("Failed to retrieve key:", error); + } finally { + setLoadingKeys((prev) => ({ ...prev, [keyId]: false })); + } + }; + const getBitlockerKeys = ApiGetCall({ url: "/api/ExecBitlockerSearch", data: { [searchType]: searchTerm }, @@ -175,7 +207,6 @@ export const CippBitlockerKeySearch = () => { > {result.keyId || "N/A"} - {result.keyId && } @@ -208,6 +239,41 @@ export const CippBitlockerKeySearch = () => { {result.tenant || "N/A"} + + + Recovery Key + + + {recoveryKeys[result.keyId] ? ( + <> + + {recoveryKeys[result.keyId]} + + + + ) : ( + + )} + + + {/* Device Information */} {result.deviceFound && ( <> @@ -237,7 +303,6 @@ export const CippBitlockerKeySearch = () => { > {result.deviceId || "N/A"} - {result.deviceId && } From c9efb18de0b16a7f2d984013be067ecc3a3c8fdf Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:04:22 +0800 Subject: [PATCH 06/64] Dead page, replaced with draw on index page --- .../administration/tenants/groups/add.js | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 src/pages/tenant/administration/tenants/groups/add.js diff --git a/src/pages/tenant/administration/tenants/groups/add.js b/src/pages/tenant/administration/tenants/groups/add.js deleted file mode 100644 index 229313fb75ad..000000000000 --- a/src/pages/tenant/administration/tenants/groups/add.js +++ /dev/null @@ -1,53 +0,0 @@ -import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; -import { useForm } from "react-hook-form"; -import { ApiPostCall } from "../../../../../api/ApiCall"; -import { Box } from "@mui/material"; -import { Grid } from "@mui/system"; -import CippPageCard from "../../../../../components/CippCards/CippPageCard"; -import { CippApiResults } from "../../../../../components/CippComponents/CippApiResults"; -import CippAddEditTenantGroups from "../../../../../components/CippComponents/CippAddEditTenantGroups"; - -const Page = () => { - const formControl = useForm({ - mode: "onChange", - }); - - const addGroupApi = ApiPostCall({ - urlFromData: true, - relatedQueryKeys: ["TenantGroupListPage"], - }); - - const handleAddGroup = (data) => { - addGroupApi.mutate({ - url: "/api/EditTenantGroup", - data: { - Action: "AddEdit", - groupName: data.groupName, - groupDescription: data.groupDescription, - }, - }); - }; - - return ( - - - - - - - - - - - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; From 023e5fddb796187a9a14abd313a1e0cf9b2f2ff1 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:46:32 +0800 Subject: [PATCH 07/64] What even is this, old dead page - add domain to cipp --- src/pages/domains.js | 149 ------------------------------------------- 1 file changed, 149 deletions(-) delete mode 100644 src/pages/domains.js diff --git a/src/pages/domains.js b/src/pages/domains.js deleted file mode 100644 index c70d96f5364b..000000000000 --- a/src/pages/domains.js +++ /dev/null @@ -1,149 +0,0 @@ -import Head from "next/head"; -import { useRef } from "react"; -import { - Alert, - Box, - Button, - Card, - CircularProgress, - Container, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Divider, - Stack, - TextField, - Typography, -} from "@mui/material"; -import { useDialog } from "../hooks/use-dialog"; -import { Layout as DashboardLayout } from "../layouts/index.js"; -import { CippDataTable } from "../components/CippTable/CippDataTable"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { ApiPostCall } from "../api/ApiCall"; -import { useFormik } from "formik"; - -const Page = () => { - const ref = useRef(); - const createDialog = useDialog(); - const domainPostRequest = ApiPostCall({ - urlFromData: true, - relatedQueryKeys: "users", - }); - - const formik = useFormik({ - initialValues: { - domainName: "", - }, - onSubmit: async (values, helpers) => { - try { - domainPostRequest.mutate({ url: "/api/AddCustomDomain", ...values }); - helpers.resetForm(); - helpers.setStatus({ success: true }); - helpers.setSubmitting(false); - } catch (err) { - helpers.setStatus({ success: false }); - helpers.setErrors({ submit: err.message }); - helpers.setSubmitting(false); - } - }, - }); - - return ( - <> - - Devices - - - - - - - - - Add Domain} - actions={[ - { - label: "Delete domain", - type: "GET", - url: "api/DeleteCustomDomain", - data: { domain: "Domain" }, - icon: , - }, - ]} - simple={false} - api={{ url: "api/ListCustomDomains" }} - columns={[ - { - header: "Domain", - accessorKey: "Domain", - }, - { - header: "Status", - accessorKey: "Status", - }, - ]} - /> - - - - - -
- Add Domain - - - To add a domain to your instance, set your preferred CNAME to your CIPP default - domain, then add the domain here. - - - - - - - {domainPostRequest.isPending && ( - - Adding domain... - - )} - {domainPostRequest.isError && ( - - Error adding domain: {domainPostRequest.error.response.data} - - )} - - - - - -
-
- - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; From 2a7e6789763e7e16087864ee51417a6529442714 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:07:00 +0800 Subject: [PATCH 08/64] Another dead page, looks to be replaced with a draw --- src/pages/tenant/manage/recover-policies.js | 200 -------------------- 1 file changed, 200 deletions(-) delete mode 100644 src/pages/tenant/manage/recover-policies.js diff --git a/src/pages/tenant/manage/recover-policies.js b/src/pages/tenant/manage/recover-policies.js deleted file mode 100644 index 16a68a469186..000000000000 --- a/src/pages/tenant/manage/recover-policies.js +++ /dev/null @@ -1,200 +0,0 @@ -import { Layout as DashboardLayout } from "../../../layouts/index.js"; -import { useRouter } from "next/router"; -import { Policy, Restore, ExpandMore } from "@mui/icons-material"; -import { - Box, - Stack, - Typography, - Accordion, - AccordionSummary, - AccordionDetails, - Chip, - Button, -} from "@mui/material"; -import { Grid } from "@mui/system"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { HeaderedTabbedLayout } from "../../../layouts/HeaderedTabbedLayout"; -import tabOptions from "./tabOptions.json"; -import { CippDataTable } from "../../../components/CippTable/CippDataTable"; -import { CippHead } from "../../../components/CippComponents/CippHead"; -import { CippFormComponent } from "../../../components/CippComponents/CippFormComponent"; -import { ApiPostCall } from "../../../api/ApiCall"; -import { CippApiResults } from "../../../components/CippComponents/CippApiResults"; -import { createDriftManagementActions } from "./driftManagementActions"; -import { useSettings } from "../../../hooks/use-settings"; - -const RecoverPoliciesPage = () => { - const router = useRouter(); - const { templateId } = router.query; - const [selectedPolicies, setSelectedPolicies] = useState([]); - const userSettings = useSettings(); - // Prioritize URL query parameter, then fall back to settings - const currentTenant = router.query.tenantFilter || userSettings.currentTenant; - - const formControl = useForm({ mode: "onChange" }); - - const selectedBackup = formControl.watch("backupDateTime"); - - // Mock data for policies in selected backup - replace with actual API call - const backupPolicies = [ - { - id: 1, - name: "Multi-Factor Authentication Policy", - type: "Conditional Access", - lastModified: "2024-01-15", - settings: "Require MFA for all users", - }, - { - id: 2, - name: "Password Policy Standard", - type: "Security Standard", - lastModified: "2024-01-10", - settings: "14 character minimum, complexity required", - }, - { - id: 3, - name: "Device Compliance Policy", - type: "Intune Policy", - lastModified: "2024-01-08", - settings: "Require encryption, PIN/Password", - }, - ]; - - // Recovery API call - const recoverApi = ApiPostCall({ - relatedQueryKeys: ["ListBackupPolicies", "ListPolicyBackups"], - }); - - const handleRecover = () => { - if (selectedPolicies.length === 0 || !selectedBackup) { - return; - } - - recoverApi.mutate({ - url: "/api/RecoverPolicies", - data: { - templateId, - backupDateTime: selectedBackup, - policyIds: selectedPolicies.map((policy) => policy.id), - }, - }); - }; - - // Actions for the ActionsMenu - const actions = createDriftManagementActions({ - templateId, - onRefresh: () => { - // Refresh any relevant data here - }, - currentTenant, - }); - - const title = "Manage Drift"; - const subtitle = [ - { - icon: , - text: `Template ID: ${templateId || "Loading..."}`, - }, - ]; - - return ( - - - - - {/* Backup Date Selection */} - - }> - - - Select Backup Date & Time - - - - - - { - const date = new Date(option.dateTime); - return `${date.toLocaleDateString()} @ ${date.toLocaleTimeString()} (${ - option.policyCount - } policies)`; - }, - valueField: "dateTime", - }} - required={true} - validators={{ - validate: (value) => !!value || "Please select a backup date & time", - }} - /> - - - - - - {/* Recovery Results */} - - - {/* Backup Policies Section */} - {selectedBackup && ( - - }> - - - Policies in Selected Backup - - - - - - - - Select policies to recover from backup:{" "} - {new Date(selectedBackup).toLocaleString()} - - - - setSelectedPolicies(selectedRows)} - /> - - - - )} - - - - ); -}; - -RecoverPoliciesPage.getLayout = (page) => {page}; - -export default RecoverPoliciesPage; From 121ba7831ff4693d0aa2bc222d70f9138041ed21 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 2 Mar 2026 13:14:35 -0500 Subject: [PATCH 09/64] fix queue tracker --- src/pages/email/administration/mailboxes/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/email/administration/mailboxes/index.js b/src/pages/email/administration/mailboxes/index.js index 19913b56cf8b..2be07f7bcde8 100644 --- a/src/pages/email/administration/mailboxes/index.js +++ b/src/pages/email/administration/mailboxes/index.js @@ -127,8 +127,8 @@ const Page = () => { Name: "Mailboxes", }, onSuccess: (response) => { - if (response?.QueueId) { - setSyncQueueId(response.QueueId); + if (response?.Metadata?.QueueId) { + setSyncQueueId(response.Metadata.QueueId); } }, }} From 811b248d7302446bf948c69ced1fb54be8c939ea Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 2 Mar 2026 13:19:37 -0500 Subject: [PATCH 10/64] Add Types property to Mailboxes data Insert a Types: "None" field into the Mailboxes to not request additional data --- src/pages/email/administration/mailboxes/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/email/administration/mailboxes/index.js b/src/pages/email/administration/mailboxes/index.js index 2be07f7bcde8..3a7af2fa1223 100644 --- a/src/pages/email/administration/mailboxes/index.js +++ b/src/pages/email/administration/mailboxes/index.js @@ -125,6 +125,7 @@ const Page = () => { relatedQueryKeys: [`ListMailboxes-${currentTenant}`], data: { Name: "Mailboxes", + Types: "None", }, onSuccess: (response) => { if (response?.Metadata?.QueueId) { From 195a81430bffec8b9ad03c3942c54231d9a8540c Mon Sep 17 00:00:00 2001 From: "Brad M.K." Date: Mon, 2 Mar 2026 20:44:37 +0000 Subject: [PATCH 11/64] Add CloudFlare Tunnel option for PWPush extension Introduce a switch field (PWPush.CFEnabled) to enable connecting to PWPush through CloudFlare Tunnel using Service Account credentials. The field is conditionally displayed when CFZTNA.Enabled is true. --- src/data/Extensions.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 21471f5e295c..30da7d2638f7 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -779,6 +779,16 @@ "compareValue": true, "action": "disable" } + }, + { + "type": "switch", + "name": "PWPush.CFEnabled", + "label": "Connect to PWPush through CloudFlare Tunnel with the Service Account credentials.", + "condition": { + "field": "CFZTNA.Enabled", + "compareType": "is", + "compareValue": true + } } ], "mappingRequired": false From d15b4df9d1e453c4645f73f4c280ac6164144ebd Mon Sep 17 00:00:00 2001 From: Woody <2997336+MWG-Logan@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:13:53 -0500 Subject: [PATCH 12/64] fix(reusable-settings): normalize RawJSON casing in templates --- .../CippReusableSettingsDeployDrawer.jsx | 5 ++++- .../endpoint/MEM/reusable-settings-templates/edit.jsx | 2 +- src/pages/endpoint/MEM/reusable-settings/edit.jsx | 10 +++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/CippComponents/CippReusableSettingsDeployDrawer.jsx b/src/components/CippComponents/CippReusableSettingsDeployDrawer.jsx index 06365e32d50c..007de36b32f1 100644 --- a/src/components/CippComponents/CippReusableSettingsDeployDrawer.jsx +++ b/src/components/CippComponents/CippReusableSettingsDeployDrawer.jsx @@ -27,11 +27,14 @@ export const CippReusableSettingsDeployDrawer = ({ const templates = ApiGetCall({ url: "/api/ListIntuneReusableSettingTemplates", queryKey: "ListIntuneReusableSettingTemplates" }); + const getRawJson = (source) => source?.RawJSON ?? source?.RAWJson ?? source?.rawJSON ?? ""; + useEffect(() => { if (templates.isSuccess && selectedTemplate?.value) { const match = templates.data?.find((t) => t.GUID === selectedTemplate.value); if (match) { - formControl.setValue("rawJSON", match.RawJSON || ""); + const rawJsonValue = getRawJson(match); + formControl.setValue("rawJSON", rawJsonValue); formControl.setValue("TemplateId", match.GUID); } } diff --git a/src/pages/endpoint/MEM/reusable-settings-templates/edit.jsx b/src/pages/endpoint/MEM/reusable-settings-templates/edit.jsx index 046a14a70d20..82baa233ba27 100644 --- a/src/pages/endpoint/MEM/reusable-settings-templates/edit.jsx +++ b/src/pages/endpoint/MEM/reusable-settings-templates/edit.jsx @@ -117,7 +117,7 @@ const EditReusableSettingsTemplate = () => { return { ...templateData, // Normalize all known casing variants to the canonical RawJSON property - RawJSON: templateData.RawJSON ?? templateData.RAWJson ?? templateData.RAWJSON, + RawJSON: templateData.RawJSON ?? templateData.RAWJson ?? templateData.rawJSON, }; }, [templateData]); diff --git a/src/pages/endpoint/MEM/reusable-settings/edit.jsx b/src/pages/endpoint/MEM/reusable-settings/edit.jsx index 3fe089ac520d..ac1285e02f42 100644 --- a/src/pages/endpoint/MEM/reusable-settings/edit.jsx +++ b/src/pages/endpoint/MEM/reusable-settings/edit.jsx @@ -36,22 +36,26 @@ const EditReusableSetting = () => { const record = Array.isArray(settingQuery.data) ? settingQuery.data[0] : settingQuery.data; + const getRawJson = (source) => source?.RawJSON ?? ""; + useEffect(() => { if (record) { + const rawJsonValue = getRawJson(record); reset({ tenantFilter: effectiveTenant, ID: record.id, displayName: record.displayName, description: record.description, - rawJSON: record.RawJSON, + rawJSON: rawJsonValue, }); } }, [record, effectiveTenant, reset]); const safeJson = () => { - if (!record?.RawJSON) return null; + const rawJsonValue = getRawJson(record); + if (!rawJsonValue) return null; try { - return JSON.parse(record.RawJSON); + return JSON.parse(rawJsonValue); } catch (e) { console.error("Failed to parse RawJSON for reusable setting preview", { error: e, From 047e9c0a1e7d1e952aa936d4f5557883bae852cb Mon Sep 17 00:00:00 2001 From: "Brad M.K." Date: Tue, 3 Mar 2026 00:49:03 +0000 Subject: [PATCH 13/64] Fix PWPush email field condition logic Change condition from "is false" to "isNot true" for PWPush.Email field visibility to ensure proper conditional rendering when bearer authentication is disabled. --- src/data/Extensions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 30da7d2638f7..6d92649f60dc 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -710,8 +710,8 @@ "placeholder": "Enter your email address for PWPush. (Email & API Key auth)", "condition": { "field": "PWPush.UseBearerAuth", - "compareType": "is", - "compareValue": false + "compareType": "isNot", + "compareValue": true } }, { From be8d5d40dde9a0ba77fb719d5c2ec3111653aed3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:12:00 +0800 Subject: [PATCH 14/64] Bitlocker search improvements :) --- .../CippCards/CippUniversalSearchV2.jsx | 160 +++++- .../CippComponents/CippBitlockerKeySearch.jsx | 490 +++++++----------- .../tenant/tools/bitlocker-search/index.js | 21 - 3 files changed, 341 insertions(+), 330 deletions(-) delete mode 100644 src/pages/tenant/tools/bitlocker-search/index.js diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index bc6fe4055dbb..0f0d6e88ad8b 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -10,27 +10,32 @@ import { CircularProgress, InputAdornment, Portal, + Button, } from "@mui/material"; import { Search as SearchIcon } from "@mui/icons-material"; import { ApiGetCall } from "../../api/ApiCall"; -import { useSettings } from "../../hooks/use-settings"; import { useRouter } from "next/router"; import { BulkActionsMenu } from "../bulk-actions-menu"; -import { Button } from "@mui/material"; +import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; +import { CippBitlockerKeySearch } from "../CippComponents/CippBitlockerKeySearch"; export const CippUniversalSearchV2 = React.forwardRef( ({ onConfirm = () => {}, onChange = () => {}, maxResults = 10, value = "" }, ref) => { const [searchValue, setSearchValue] = useState(value); const [searchType, setSearchType] = useState("Users"); + const [bitlockerLookupType, setBitlockerLookupType] = useState("keyId"); const [showDropdown, setShowDropdown] = useState(false); + const [bitlockerDrawerVisible, setBitlockerDrawerVisible] = useState(false); + const [bitlockerDrawerDefaults, setBitlockerDrawerDefaults] = useState({ + searchTerm: "", + searchType: "keyId", + }); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); const containerRef = useRef(null); const textFieldRef = useRef(null); const router = useRouter(); - const settings = useSettings(); - const { currentTenant } = settings; - const search = ApiGetCall({ + const universalSearch = ApiGetCall({ url: `/api/ExecUniversalSearchV2`, data: { searchTerms: searchValue, @@ -41,6 +46,17 @@ export const CippUniversalSearchV2 = React.forwardRef( waiting: false, }); + const bitlockerSearch = ApiGetCall({ + url: "/api/ExecBitlockerSearch", + data: { + [bitlockerLookupType]: searchValue, + }, + queryKey: `bitlocker-universal-${bitlockerLookupType}-${searchValue}`, + waiting: false, + }); + + const activeSearch = searchType === "BitLocker" ? bitlockerSearch : universalSearch; + const handleChange = (event) => { const newValue = event.target.value; setSearchValue(newValue); @@ -71,7 +87,7 @@ export const CippUniversalSearchV2 = React.forwardRef( const handleSearch = () => { if (searchValue.length > 0) { updateDropdownPosition(); - search.refetch(); + activeSearch.refetch(); setShowDropdown(true); } }; @@ -93,6 +109,21 @@ export const CippUniversalSearchV2 = React.forwardRef( const handleTypeChange = (type) => { setSearchType(type); + if (type === "BitLocker") { + setBitlockerLookupType("keyId"); + } + setShowDropdown(false); + }; + + const handleBitlockerResultClick = (match) => { + setBitlockerDrawerDefaults({ + searchTerm: + bitlockerLookupType === "deviceId" + ? match?.deviceId || searchValue + : match?.keyId || searchValue, + searchType: bitlockerLookupType, + }); + setBitlockerDrawerVisible(true); setShowDropdown(false); }; @@ -107,6 +138,24 @@ export const CippUniversalSearchV2 = React.forwardRef( icon: "Group", onClick: () => handleTypeChange("Groups"), }, + { + label: "BitLocker", + icon: "FilePresent", + onClick: () => handleTypeChange("BitLocker"), + }, + ]; + + const bitlockerLookupActions = [ + { + label: "Key ID", + icon: "FilePresent", + onClick: () => setBitlockerLookupType("keyId"), + }, + { + label: "Device ID", + icon: "Laptop", + onClick: () => setBitlockerLookupType("deviceId"), + }, ]; // Close dropdown when clicking outside @@ -144,7 +193,12 @@ export const CippUniversalSearchV2 = React.forwardRef( } }, [showDropdown]); - const hasResults = Array.isArray(search?.data) && search.data.length > 0; + const bitlockerResults = Array.isArray(bitlockerSearch?.data?.Results) + ? bitlockerSearch.data.Results + : []; + const universalResults = Array.isArray(universalSearch?.data) ? universalSearch.data : []; + const hasResults = + searchType === "BitLocker" ? bitlockerResults.length > 0 : universalResults.length > 0; const shouldShowDropdown = showDropdown && searchValue.length > 0; const getLabel = () => { @@ -152,6 +206,10 @@ export const CippUniversalSearchV2 = React.forwardRef( return "Search users by UPN or Display Name"; } else if (searchType === "Groups") { return "Search groups by Display Name"; + } else if (searchType === "BitLocker") { + return bitlockerLookupType === "deviceId" + ? "Search BitLocker by Device ID" + : "Search BitLocker by Recovery Key ID"; } return "Search"; }; @@ -163,6 +221,12 @@ export const CippUniversalSearchV2 = React.forwardRef( buttonName={searchType} actions={typeMenuActions} /> + {searchType === "BitLocker" && ( + + )} { textFieldRef.current = node; @@ -187,7 +251,7 @@ export const CippUniversalSearchV2 = React.forwardRef( ), - endAdornment: search.isFetching ? ( + endAdornment: activeSearch.isFetching ? ( @@ -203,7 +267,7 @@ export const CippUniversalSearchV2 = React.forwardRef( - + + )} - {/* Results Section */} - {getBitlockerKeys.isFetching ? ( + {isSuccess && ( + <> - - - Searching... - - - - ) : getBitlockerKeys.isSuccess ? ( - <> - - - - - {results.length === 0 ? ( - - }> - No BitLocker keys found matching your search criteria. - - - ) : ( - - - Found {results.length} BitLocker Key{results.length !== 1 ? "s" : ""} - - - {results.map((result, index) => ( - - - {/* BitLocker Key Information */} - - - - BitLocker Key Information - - + {results.map((result, index) => ( + + + {/* BitLocker Key Information */} + + + + BitLocker Key Information + + - - - Key ID + + + Key ID + + + + {result.keyId || "N/A"} - - - {result.keyId || "N/A"} - - - + + - - - Volume Type - - - + + + Volume Type + + + - - - Created - - - {result.createdDateTime - ? new Date(result.createdDateTime).toLocaleString() - : "N/A"} - - + + + Created + + + {result.createdDateTime + ? new Date(result.createdDateTime).toLocaleString() + : "N/A"} + + - - - Tenant - - {result.tenant || "N/A"} - + + + Tenant + + {result.tenant || "N/A"} + - - - Recovery Key - - - {recoveryKeys[result.keyId] ? ( - <> - - {recoveryKeys[result.keyId]} - - - - ) : ( - - )} - - - - {/* Device Information */} - {result.deviceFound && ( - <> - - - - - Device Information + {recoveryKeys[result.keyId]} - + + + ) : ( + + )} + + - - - Device Name - - {result.deviceName || "N/A"} - + {/* Device Information */} + {result.deviceFound && ( + <> + + + + + Device Information + + - - - Device ID - - - - {result.deviceId || "N/A"} - - - + + + Device Name + + {result.deviceName || "N/A"} + - - - Operating System - - - {result.operatingSystem || "N/A"} - {result.osVersion && ` (${result.osVersion})`} + + + Device ID + + + + {result.deviceId || "N/A"} - + + - - - Account Status - - - ) : ( - - ) - } - label={result.accountEnabled ? "Enabled" : "Disabled"} - size="small" - color={result.accountEnabled ? "success" : "default"} - /> - + + + Operating System + + + {result.operatingSystem || "N/A"} + {result.osVersion && ` (${result.osVersion})`} + + - - - Trust Type - - {result.trustType || "N/A"} - + + + Account Status + + + ) : ( + + ) + } + label={result.accountEnabled ? "Enabled" : "Disabled"} + size="small" + color={result.accountEnabled ? "success" : "default"} + /> + - - - Last Sign In - - - {result.lastSignIn - ? new Date(result.lastSignIn).toLocaleString() - : "N/A"} - - - - )} + + + Trust Type + + {result.trustType || "N/A"} + - {!result.deviceFound && ( - - }> - Device information not found in cache. The device may have been deleted - or not yet synced. - + + + Last Sign In + + + {result.lastSignIn ? new Date(result.lastSignIn).toLocaleString() : "N/A"} + - )} - - - ))} - - )} - - ) : getBitlockerKeys.isError ? ( - - - - Error searching for BitLocker keys: {getBitlockerKeys.error?.message} - + + )} + + {!result.deviceFound && ( + + }> + Device information not found in cache. The device may have been deleted or + not yet synced. + + + )} + + + ))} - ) : null} - - + + )} + ); + return content; }; export default CippBitlockerKeySearch; diff --git a/src/pages/tenant/tools/bitlocker-search/index.js b/src/pages/tenant/tools/bitlocker-search/index.js deleted file mode 100644 index d445b0b0632e..000000000000 --- a/src/pages/tenant/tools/bitlocker-search/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import { Box, Container } from "@mui/material"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import CippBitlockerKeySearch from "../../../../components/CippComponents/CippBitlockerKeySearch"; - -const Page = () => { - return ( - - - - - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; From d1626766ae65593648f6cc6616fd32a872666263 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:08:10 -0600 Subject: [PATCH 15/64] Update authentication link in CippDirectTenantDeploy --- src/components/CippWizard/CippDirectTenantDeploy.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CippWizard/CippDirectTenantDeploy.jsx b/src/components/CippWizard/CippDirectTenantDeploy.jsx index 5c34e8e76806..b4a24d77f3fa 100644 --- a/src/components/CippWizard/CippDirectTenantDeploy.jsx +++ b/src/components/CippWizard/CippDirectTenantDeploy.jsx @@ -35,7 +35,7 @@ export const CippDirectTenantDeploy = (props) => { You can authenticate to multiple tenants by repeating this step for each tenant you want to add. More information about per-tenant authentication can be found in the{" "} From e4e484bdfbd1e458c300d1d3143c73d1bcf5e390 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:09:09 -0600 Subject: [PATCH 16/64] Update GDAP documentation link in CippGDAPTenantSetup --- src/components/CippWizard/CippGDAPTenantSetup.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CippWizard/CippGDAPTenantSetup.jsx b/src/components/CippWizard/CippGDAPTenantSetup.jsx index d64b2bd4f702..d326bff26729 100644 --- a/src/components/CippWizard/CippGDAPTenantSetup.jsx +++ b/src/components/CippWizard/CippGDAPTenantSetup.jsx @@ -108,7 +108,7 @@ export const CippGDAPTenantSetup = (props) => { This process will help you set up a new GDAP relationship with a customer tenant. You'll generate an invite that the customer needs to accept before completing onboarding. For more information about GDAP setup, visit the{" "} - + GDAP documentation . From c702e0efb51fb4e4fae584ef7942e399d3648878 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:09:36 -0600 Subject: [PATCH 17/64] Update service account documentation link --- src/components/CippWizard/CippTenantModeDeploy.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CippWizard/CippTenantModeDeploy.jsx b/src/components/CippWizard/CippTenantModeDeploy.jsx index aae77e6ab265..c14bb0aa573b 100644 --- a/src/components/CippWizard/CippTenantModeDeploy.jsx +++ b/src/components/CippWizard/CippTenantModeDeploy.jsx @@ -77,7 +77,7 @@ export const CippTenantModeDeploy = (props) => { Please remember to log onto a service account dedicated for CIPP. More info? Check out the{" "} From 8368043e3c86116f1ef3541a5b41b7adbf0b766d Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:10:48 -0600 Subject: [PATCH 18/64] Update link to recommended roles documentation --- .../tenant/gdap-management/relationships/relationship/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tenant/gdap-management/relationships/relationship/index.js b/src/pages/tenant/gdap-management/relationships/relationship/index.js index 9c9bfee83400..17231abdb42f 100644 --- a/src/pages/tenant/gdap-management/relationships/relationship/index.js +++ b/src/pages/tenant/gdap-management/relationships/relationship/index.js @@ -177,7 +177,7 @@ const Page = () => { This relationship does not have all the CIPP recommended roles. See the{" "} From e4417b2fe69ffe6740307fc3adcc47ab737e02bb Mon Sep 17 00:00:00 2001 From: "Brad M.K." Date: Tue, 3 Mar 2026 22:29:44 +0000 Subject: [PATCH 19/64] Add default passphrase field to PWPush extension Insert a password field (PWPush.DefaultPassphrase) to allow setting a default passphrase required to view pushed passwords. The field is conditionally disabled when PWPush.Enabled is false. --- src/data/Extensions.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 6d92649f60dc..00977617300d 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -780,6 +780,18 @@ "action": "disable" } }, + { + "type": "password", + "name": "PWPush.DefaultPassphrase", + "label": "Default Passphrase", + "placeholder": "Enter a default passphrase required to view pushed passwords. (optional)", + "condition": { + "field": "PWPush.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, { "type": "switch", "name": "PWPush.CFEnabled", From 9d7e9d5d8bd22af67cec5c317ba8b69f87819108 Mon Sep 17 00:00:00 2001 From: "Brad M.K." Date: Tue, 3 Mar 2026 23:11:21 +0000 Subject: [PATCH 20/64] Update PWPush retrieval step label to clarify passphrase recommendation Modify the RetrievalStep field label to indicate that clicking to retrieve password is recommended specifically when a passphrase is not set. --- src/data/Extensions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 00977617300d..c5d752f9bdbf 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -761,7 +761,7 @@ { "type": "switch", "name": "PWPush.RetrievalStep", - "label": "Click to retrieve password (recommended)", + "label": "Click to retrieve password (recommended if passphrase is not set)", "condition": { "field": "PWPush.Enabled", "compareType": "is", From 70486bfcc3df84755d928aa71f99e04f0f553d62 Mon Sep 17 00:00:00 2001 From: "Brad M.K." Date: Tue, 3 Mar 2026 23:17:19 +0000 Subject: [PATCH 21/64] Reorder PWPush extension fields and update CF-ZTNA label Move DefaultPassphrase field before RetrievalStep and DeletableByViewer fields in PWPush extension configuration. Simplify CFEnabled label from "Connect to PWPush through CloudFlare Tunnel with the Service Account credentials" to "Behind a CF-ZTNA Tunnel". --- src/data/Extensions.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/data/Extensions.json b/src/data/Extensions.json index c5d752f9bdbf..52df55dd4726 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -759,9 +759,10 @@ } }, { - "type": "switch", - "name": "PWPush.RetrievalStep", - "label": "Click to retrieve password (recommended if passphrase is not set)", + "type": "password", + "name": "PWPush.DefaultPassphrase", + "label": "Default Passphrase", + "placeholder": "Enter a default passphrase required to view pushed passwords. (optional)", "condition": { "field": "PWPush.Enabled", "compareType": "is", @@ -771,8 +772,8 @@ }, { "type": "switch", - "name": "PWPush.DeletableByViewer", - "label": "Allow deletion of passwords", + "name": "PWPush.RetrievalStep", + "label": "Click to retrieve password (recommended if passphrase is not set)", "condition": { "field": "PWPush.Enabled", "compareType": "is", @@ -781,10 +782,9 @@ } }, { - "type": "password", - "name": "PWPush.DefaultPassphrase", - "label": "Default Passphrase", - "placeholder": "Enter a default passphrase required to view pushed passwords. (optional)", + "type": "switch", + "name": "PWPush.DeletableByViewer", + "label": "Allow deletion of passwords", "condition": { "field": "PWPush.Enabled", "compareType": "is", @@ -795,7 +795,7 @@ { "type": "switch", "name": "PWPush.CFEnabled", - "label": "Connect to PWPush through CloudFlare Tunnel with the Service Account credentials.", + "label": "Behind a CF-ZTNA Tunnel", "condition": { "field": "CFZTNA.Enabled", "compareType": "is", From fd766d35573ddcb1bfb700da632cb7e21040354f Mon Sep 17 00:00:00 2001 From: "Brad M.K." Date: Wed, 4 Mar 2026 03:11:04 +0000 Subject: [PATCH 22/64] Add bookmark management to sidebar and top navigation with drag-and-drop reordering Introduce a new SideNavBookmarks component that displays bookmarks in both desktop sidebar and mobile navigation. Add drag-and-drop functionality to reorder bookmarks, replacing the previous up/down arrow controls. Include a bookmarkMode setting to control bookmark display location (sidebar, topnav, or both). Update CippSettingsSideBar to persist the bookmarkMode preference. --- .../CippComponents/CippSettingsSideBar.jsx | 3 + src/layouts/mobile-nav.js | 14 +- src/layouts/side-nav-bookmarks.js | 259 ++++++++++++++++++ src/layouts/side-nav.js | 14 +- src/layouts/top-nav.js | 252 +++++++++-------- src/pages/cipp/preferences.js | 18 ++ 6 files changed, 430 insertions(+), 130 deletions(-) create mode 100644 src/layouts/side-nav-bookmarks.js diff --git a/src/components/CippComponents/CippSettingsSideBar.jsx b/src/components/CippComponents/CippSettingsSideBar.jsx index 693c55673d77..90839464161a 100644 --- a/src/components/CippComponents/CippSettingsSideBar.jsx +++ b/src/components/CippComponents/CippSettingsSideBar.jsx @@ -65,6 +65,9 @@ export const CippSettingsSideBar = (props) => { // Table Filter Preferences persistFilters: formValues.persistFilters, + // Bookmark Display Mode + bookmarkMode: formValues.bookmarkMode, + // Portal Links Configuration portalLinks: { M365_Portal: formValues.portalLinks?.M365_Portal, diff --git a/src/layouts/mobile-nav.js b/src/layouts/mobile-nav.js index 30612f3fd28a..728cb05bb083 100644 --- a/src/layouts/mobile-nav.js +++ b/src/layouts/mobile-nav.js @@ -1,12 +1,14 @@ import NextLink from "next/link"; import { usePathname } from "next/navigation"; import PropTypes from "prop-types"; -import { Box, Drawer, Stack } from "@mui/material"; +import { Box, Divider, Drawer, Stack } from "@mui/material"; import { Logo } from "../components/logo"; import { Scrollbar } from "../components/scrollbar"; import { paths } from "../paths"; import { MobileNavItem } from "./mobile-nav-item"; +import { SideNavBookmarks } from "./side-nav-bookmarks"; import { CippTenantSelector } from "../components/CippComponents/CippTenantSelector"; +import { useSettings } from "../hooks/use-settings"; const MOBILE_NAV_WIDTH = "80%"; @@ -77,6 +79,8 @@ const reduceChildRoutes = ({ acc, depth, item, pathname }) => { export const MobileNav = (props) => { const { open, onClose, items } = props; const pathname = usePathname(); + const settings = useSettings(); + const bookmarkMode = settings.bookmarkMode?.value || "both"; return ( { p: 0, }} > + {/* Bookmarks section above Dashboard */} + {(bookmarkMode === "sidebar" || bookmarkMode === "both") && ( + <> + + + + )} + {/* Render all menu items */} {renderItems({ depth: 0, items, diff --git a/src/layouts/side-nav-bookmarks.js b/src/layouts/side-nav-bookmarks.js new file mode 100644 index 000000000000..451e0e354035 --- /dev/null +++ b/src/layouts/side-nav-bookmarks.js @@ -0,0 +1,259 @@ +import { useCallback, useState, useEffect, useRef } from "react"; +import NextLink from "next/link"; +import { + Box, + ButtonBase, + Collapse, + IconButton, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import BookmarkIcon from "@mui/icons-material/Bookmark"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import CloseIcon from "@mui/icons-material/Close"; +import ChevronRightIcon from "@heroicons/react/24/outline/ChevronRightIcon"; +import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; +import { useSettings } from "../hooks/use-settings"; + +export const SideNavBookmarks = ({ collapse = false }) => { + const settings = useSettings(); + const [open, setOpen] = useState(settings.bookmarksOpen ?? false); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const handleToggle = useCallback(() => { + setOpen((prev) => { + const next = !prev; + settings.handleUpdate({ bookmarksOpen: next }); + return next; + }); + }, [settings]); + + const removeBookmark = useCallback( + (index) => { + const updatedBookmarks = [...(settings.bookmarks || [])]; + updatedBookmarks.splice(index, 1); + settings.handleUpdate({ bookmarks: updatedBookmarks }); + }, + [settings] + ); + + const handleDragStart = useCallback((index) => { + setDragIndex(index); + }, []); + + const handleDragOver = useCallback((e, index) => { + e.preventDefault(); + setDragOverIndex(index); + }, []); + + const handleDrop = useCallback( + (e, dropIndex) => { + e.preventDefault(); + if (dragIndex === null || dragIndex === dropIndex) { + setDragIndex(null); + setDragOverIndex(null); + return; + } + const items = [...(settings.bookmarks || [])]; + const [reordered] = items.splice(dragIndex, 1); + items.splice(dropIndex, 0, reordered); + settings.handleUpdate({ bookmarks: items }); + setDragIndex(null); + setDragOverIndex(null); + }, + [dragIndex, settings] + ); + + const handleDragEnd = useCallback(() => { + setDragIndex(null); + setDragOverIndex(null); + }, []); + + const displayBookmarks = settings.bookmarks || []; + + return ( +
  • + + theme.typography.fontFamily, + fontSize: 14, + fontWeight: 500, + justifyContent: "flex-start", + px: "6px", + py: "12px", + textAlign: "left", + whiteSpace: "nowrap", + width: "100%", + }} + > + + + + + + + Bookmarks + + + {open ? : } + + + + + + {displayBookmarks.length === 0 ? ( +
  • + + No bookmarks added yet + +
  • + ) : ( + displayBookmarks.map((bookmark, idx) => ( +
  • handleDragStart(idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={(e) => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + > + + + + + theme.typography.fontFamily, + fontSize: 13, + fontWeight: 500, + justifyContent: "flex-start", + py: "6px", + textAlign: "left", + whiteSpace: "nowrap", + flexGrow: 1, + overflow: "hidden", + }} + > + + {bookmark.label} + + + + { + e.preventDefault(); + removeBookmark(idx); + }} + sx={{ p: "2px" }} + > + + + + +
  • + )) + )} + + + + ); +}; diff --git a/src/layouts/side-nav.js b/src/layouts/side-nav.js index fc2d19b396df..9605ecf75ce1 100644 --- a/src/layouts/side-nav.js +++ b/src/layouts/side-nav.js @@ -1,11 +1,13 @@ import { useState } from "react"; import { usePathname } from "next/navigation"; import PropTypes from "prop-types"; -import { Box, Drawer, Stack } from "@mui/material"; +import { Box, Divider, Drawer, Stack } from "@mui/material"; import { Scrollbar } from "../components/scrollbar"; import { SideNavItem } from "./side-nav-item"; +import { SideNavBookmarks } from "./side-nav-bookmarks"; import { ApiGetCall } from "../api/ApiCall.jsx"; import { CippSponsor } from "../components/CippComponents/CippSponsor"; +import { useSettings } from "../hooks/use-settings"; const SIDE_NAV_WIDTH = 270; const SIDE_NAV_COLLAPSED_WIDTH = 73; // icon size + padding + border right @@ -105,6 +107,8 @@ export const SideNav = (props) => { const [hovered, setHovered] = useState(false); const collapse = !(pinned || hovered); const { data: profile } = ApiGetCall({ url: "/api/me", queryKey: "authmecipp" }); + const settings = useSettings(); + const bookmarkMode = settings.bookmarkMode?.value || "both"; // Preprocess items to mark which should be open const processedItems = markOpenItems(items, pathname); @@ -159,6 +163,14 @@ export const SideNav = (props) => { p: 0, }} > + {/* Bookmarks section above Dashboard */} + {(bookmarkMode === "sidebar" || bookmarkMode === "both") && ( + <> + + + + )} + {/* Render all menu items */} {renderItems({ collapse, depth: 0, diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index ec7f06c7f3dd..a1ad87c3e868 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -5,10 +5,8 @@ import Bars3Icon from "@heroicons/react/24/outline/Bars3Icon"; import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; import SunIcon from "@heroicons/react/24/outline/SunIcon"; import BookmarkIcon from "@mui/icons-material/Bookmark"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import CloseIcon from "@mui/icons-material/Close"; import { Box, Divider, @@ -31,7 +29,6 @@ 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 { applySort } from "../utils/apply-sort"; const TOP_NAV_HEIGHT = 64; @@ -40,6 +37,7 @@ export const TopNav = (props) => { const { onNavOpen } = props; const settings = useSettings(); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const bookmarkMode = settings.bookmarkMode?.value || "both"; const handleThemeSwitch = useCallback(() => { const themeName = settings.currentTheme?.value === "light" ? "dark" : "light"; settings.handleUpdate({ @@ -49,7 +47,8 @@ export const TopNav = (props) => { }, [settings]); const [anchorEl, setAnchorEl] = useState(null); - const [sortOrder, setSortOrder] = useState("asc"); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); const handleBookmarkClick = (event) => { setAnchorEl(event.currentTarget); @@ -59,43 +58,45 @@ export const TopNav = (props) => { setAnchorEl(null); }; - const handleSortToggle = () => { - const newSortOrder = sortOrder === "asc" ? "desc" : "asc"; - setSortOrder(newSortOrder); - - // Save the new sort order and re-order bookmarks - const sortedBookmarks = applySort(settings.bookmarks || [], "label", newSortOrder); - settings.handleUpdate({ - bookmarks: sortedBookmarks, - sortOrder: newSortOrder, - }); + const handleDragStart = (index) => { + setDragIndex(index); }; - // Move a bookmark up in the list - const moveBookmarkUp = (index) => { - if (index <= 0) return; - - const updatedBookmarks = [...(settings.bookmarks || [])]; - const temp = updatedBookmarks[index]; - updatedBookmarks[index] = updatedBookmarks[index - 1]; - updatedBookmarks[index - 1] = temp; - - settings.handleUpdate({ bookmarks: updatedBookmarks }); + const handleDragOver = (e, index) => { + e.preventDefault(); + setDragOverIndex(index); }; - // Move a bookmark down in the list - const moveBookmarkDown = (index) => { - const bookmarks = settings.bookmarks || []; - if (index >= bookmarks.length - 1) return; + const handleDrop = (e, dropIndex) => { + e.preventDefault(); + if (dragIndex === null || dragIndex === dropIndex) { + setDragIndex(null); + setDragOverIndex(null); + return; + } + const items = [...(settings.bookmarks || [])]; + const [reordered] = items.splice(dragIndex, 1); + items.splice(dropIndex, 0, reordered); + settings.handleUpdate({ bookmarks: items }); + setDragIndex(null); + setDragOverIndex(null); + }; - const updatedBookmarks = [...bookmarks]; - const temp = updatedBookmarks[index]; - updatedBookmarks[index] = updatedBookmarks[index + 1]; - updatedBookmarks[index + 1] = temp; + const handleDragEnd = () => { + setDragIndex(null); + setDragOverIndex(null); + }; + const removeBookmark = (index) => { + const updatedBookmarks = [...(settings.bookmarks || [])]; + updatedBookmarks.splice(index, 1); settings.handleUpdate({ bookmarks: updatedBookmarks }); }; + const displayBookmarks = settings.bookmarks || []; + const popoverOpen = Boolean(anchorEl); + const popoverId = popoverOpen ? "bookmark-popover" : undefined; + useEffect(() => { const handleKeyDown = (event) => { if ((event.metaKey || event.ctrlKey) && event.key === "k") { @@ -109,22 +110,10 @@ export const TopNav = (props) => { }; }, []); - useEffect(() => { - if (settings.sortOrder) { - setSortOrder(settings.sortOrder); - } - }, [settings.sortOrder]); - const openSearch = () => { searchDialog.handleOpen(); }; - // Use the sorted bookmarks if sorting is applied, otherwise use the bookmarks in their current order - const displayBookmarks = settings.bookmarks || []; - - const open = Boolean(anchorEl); - const id = open ? "bookmark-popover" : undefined; - return ( { - - - - - - - - - - - {sortOrder === "asc" ? : } - - - Sort Alphabetically - - {displayBookmarks.length === 0 ? ( - - No bookmarks added yet
    } - /> - - ) : ( - displayBookmarks.map((bookmark, idx) => ( - - handleBookmarkClose()} - sx={{ - textDecoration: "none", - color: "inherit", - flexGrow: 1, - marginRight: 2, - }} - > - {bookmark.label} - - - { - e.preventDefault(); - moveBookmarkUp(idx); + {(bookmarkMode === "popover" || bookmarkMode === "both") && ( + <> + + + + + + + + {displayBookmarks.length === 0 ? ( + + No bookmarks added yet} + /> + + ) : ( + displayBookmarks.map((bookmark, idx) => ( + handleDragStart(idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={(e) => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + sx={{ + color: "inherit", + display: "flex", + justifyContent: "space-between", + cursor: "grab", + ...(dragIndex === idx && { + opacity: 0.4, + }), + ...(dragOverIndex === idx && dragIndex !== idx && { + borderTop: "2px solid", + borderColor: "primary.main", + }), }} - disabled={idx === 0} > - - - { - e.preventDefault(); - moveBookmarkDown(idx); - }} - disabled={idx === displayBookmarks.length - 1} - > - - - - - )) - )} - - + + + + handleBookmarkClose()} + sx={{ + textDecoration: "none", + color: "inherit", + flexGrow: 1, + marginRight: 2, + }} + > + {bookmark.label} + + { + e.preventDefault(); + removeBookmark(idx); + }} + > + + + + )) + )} + + + + )} { /> ), }, + { + label: "Bookmark Display Mode", + value: ( + + ), + }, ]} /> From c48361f3f7adbe6c7d2caa60f60fdffed13898b2 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:38:05 +0100 Subject: [PATCH 23/64] fix: update CA Test Results table columns and fetching state - Changed column name from 'reasons' to 'analysisReasons'. - Added isFetching prop to indicate loading state for the data table. --- .../identity/administration/users/user/conditional-access.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/identity/administration/users/user/conditional-access.jsx b/src/pages/identity/administration/users/user/conditional-access.jsx index ea58b93207dc..d6bd88803c0b 100644 --- a/src/pages/identity/administration/users/user/conditional-access.jsx +++ b/src/pages/identity/administration/users/user/conditional-access.jsx @@ -246,9 +246,9 @@ const Page = () => { From 10f74a905efb60a6b98d22f078568a1c2a745d3f Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:57:13 +0100 Subject: [PATCH 24/64] feat: add authentication flow selection and reorder parameters - Introduced a new component for selecting the authentication flow. - Reordered the optional parameters for better user experience. --- .../users/user/conditional-access.jsx | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/src/pages/identity/administration/users/user/conditional-access.jsx b/src/pages/identity/administration/users/user/conditional-access.jsx index d6bd88803c0b..87c981111f19 100644 --- a/src/pages/identity/administration/users/user/conditional-access.jsx +++ b/src/pages/identity/administration/users/user/conditional-access.jsx @@ -154,28 +154,6 @@ const Page = () => { {/* Optional Parameters */} Optional Parameters: - - {/* Test from this country */} - ({ - value: Code, - label: Name, - }))} - formControl={formControl} - /> - - {/* Test from this IP */} - - {/* Device Platform */} { formControl={formControl} /> + {/* Authentication Flow */} + + + {/* Test from this IP */} + + + {/* Test from this country */} + ({ + value: Code, + label: Name, + }))} + formControl={formControl} + /> + {/* Sign-in risk level */} Date: Wed, 4 Mar 2026 20:11:43 +0100 Subject: [PATCH 25/64] fix: update autoComplete fields to disable creatable option - Set creatable to false for various autoComplete fields to restrict user input to predefined options. - Ensured multiple selection is disabled for all relevant fields. --- .../users/user/conditional-access.jsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/pages/identity/administration/users/user/conditional-access.jsx b/src/pages/identity/administration/users/user/conditional-access.jsx index 87c981111f19..8449148b8562 100644 --- a/src/pages/identity/administration/users/user/conditional-access.jsx +++ b/src/pages/identity/administration/users/user/conditional-access.jsx @@ -133,6 +133,7 @@ const Page = () => { label="Select the application to test" name="includeApplications" multiple={false} + creatable={false} api={{ tenantFilter: tenant, url: "/api/ListGraphRequest", @@ -149,6 +150,7 @@ const Page = () => { $top: 999, }, }} + validators={{ required: "Application is required" }} formControl={formControl} /> @@ -159,6 +161,8 @@ const Page = () => { type="autoComplete" label="Select the device platform to test" name="devicePlatform" + multiple={false} + creatable={false} options={[ { value: "Windows", label: "Windows" }, { value: "iOS", label: "iOS" }, @@ -174,6 +178,8 @@ const Page = () => { type="autoComplete" label="Select the client application type to test" name="clientAppType" + multiple={false} + creatable={false} options={[ { value: "all", label: "All" }, { value: "Browser", label: "Browser" }, @@ -193,6 +199,8 @@ const Page = () => { type="autoComplete" label="Select the authentication flow" name="authenticationFlow" + multiple={false} + creatable={false} options={[ { value: "none", label: "None" }, { value: "deviceCodeFlow", label: "Device code flow" }, @@ -215,6 +223,8 @@ const Page = () => { type="autoComplete" label="Test from this country" name="country" + multiple={false} + creatable={false} options={countryList.map(({ Code, Name }) => ({ value: Code, label: Name, @@ -227,6 +237,8 @@ const Page = () => { type="autoComplete" label="Select the sign-in risk level of the user signing in" name="SignInRiskLevel" + multiple={false} + creatable={false} options={[ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, @@ -241,6 +253,8 @@ const Page = () => { type="autoComplete" label="Select the user risk level of the user signing in" name="userRiskLevel" + multiple={false} + creatable={false} options={[ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, From c0bdac841ec1a21de4924935ecf7aa430878dc87 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:48:02 +0100 Subject: [PATCH 26/64] fix: restore Shift+Home text selection in autocomplete inputs MUI's Autocomplete intercepted the Home/End keys for list navigation, preventing native browser shortcuts like Shift+Home from working in the input field. Expose handleHomeEndKeys as a prop defaulting to false so callsites can opt back in where list-jump behavior is preferred. --- src/components/CippComponents/CippAutocomplete.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index d09551f1f316..bea469290be3 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -82,6 +82,7 @@ export const CippAutoComplete = (props) => { groupBy, renderGroup, customAction, + handleHomeEndKeys = false, ...other } = props; @@ -311,6 +312,7 @@ export const CippAutoComplete = (props) => { setOpen(true)} onClose={(event, reason) => { @@ -422,7 +424,7 @@ export const CippAutoComplete = (props) => { if (input) { input.focus(); } - + // Restore the scroll position if (listboxRef.current && scrollPositionRef.current > 0) { listboxRef.current.scrollTop = scrollPositionRef.current; From 4877f71d380c03930c9958c973d8b88ebc042b20 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:27:43 +0100 Subject: [PATCH 27/64] feat: add Ctrl+Alt+K shortcut to focus tenant selector Exposes a focus() handle on CippAutoComplete via forwardRef + useImperativeHandle, threads it through CippTenantSelector, and registers a Ctrl+Alt+K keydown handler in TopNav that focuses and selects-all the tenant input for quick keyboard-driven tenant switching. --- .../CippComponents/CippAutocomplete.jsx | 15 +++++++++++--- .../CippComponents/CippTenantSelector.jsx | 9 ++++++--- src/layouts/top-nav.js | 20 +++++++++++-------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index bea469290be3..a27bbe535e8a 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -10,7 +10,7 @@ import { Typography, } from "@mui/material"; import Link from "next/link"; -import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { useEffect, useState, useMemo, useCallback, useRef, useImperativeHandle } from "react"; import { useSettings } from "../../hooks/use-settings"; import { getCippError } from "../../utils/get-cipp-error"; import { ApiGetCallWithPagination } from "../../api/ApiCall"; @@ -57,7 +57,7 @@ const MemoTextField = React.memo(function MemoTextField({ ); }); -export const CippAutoComplete = (props) => { +export const CippAutoComplete = React.forwardRef((props, ref) => { const { size, api, @@ -90,6 +90,14 @@ export const CippAutoComplete = (props) => { const [getRequestInfo, setGetRequestInfo] = useState({ url: "", waiting: false, queryKey: "" }); const hasPreselectedRef = useRef(false); const autocompleteRef = useRef(null); // Ref for focusing input after selection + + useImperativeHandle(ref, () => ({ + focus() { + const input = autocompleteRef.current?.querySelector("input"); + input?.focus(); + input?.select(); + }, + }), []); const listboxRef = useRef(null); // Ref for the listbox to preserve scroll position const scrollPositionRef = useRef(0); // Store scroll position const filter = createFilterOptions({ @@ -682,4 +690,5 @@ export const CippAutoComplete = (props) => { )} ); -}; +}); +CippAutoComplete.displayName = "CippAutoComplete"; diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index ab9e50ea8fcf..f215f9879006 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -19,14 +19,14 @@ import { ServerIcon, UsersIcon, } from "@heroicons/react/24/outline"; -import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useRouter } from "next/router"; import { CippOffCanvas } from "./CippOffCanvas"; import { useSettings } from "../../hooks/use-settings"; import { getCippError } from "../../utils/get-cipp-error"; import { useQueryClient } from "@tanstack/react-query"; -export const CippTenantSelector = (props) => { +export const CippTenantSelector = React.forwardRef((props, ref) => { const { width, allTenants = false, multiple = false, refreshButton, tenantButton } = props; //get the current tenant from SearchParams called 'tenantFilter' const router = useRouter(); @@ -325,6 +325,7 @@ export const CippTenantSelector = (props) => { )} { /> ); -}; +}); + +CippTenantSelector.displayName = "CippTenantSelector"; CippTenantSelector.propTypes = { allTenants: PropTypes.bool, diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index ec7f06c7f3dd..392f6b7d148c 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import NextLink from "next/link"; import PropTypes from "prop-types"; import Bars3Icon from "@heroicons/react/24/outline/Bars3Icon"; @@ -37,6 +37,7 @@ const TOP_NAV_HEIGHT = 64; export const TopNav = (props) => { const searchDialog = useDialog(); + const tenantSelectorRef = useRef(null); const { onNavOpen } = props; const settings = useSettings(); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); @@ -96,9 +97,16 @@ export const TopNav = (props) => { settings.handleUpdate({ bookmarks: updatedBookmarks }); }; + const openSearch = useCallback(() => { + searchDialog.handleOpen(); + }, [searchDialog.handleOpen]); + useEffect(() => { const handleKeyDown = (event) => { - if ((event.metaKey || event.ctrlKey) && event.key === "k") { + if ((event.metaKey || event.ctrlKey) && event.altKey && event.key === "k") { + event.preventDefault(); + tenantSelectorRef.current?.focus(); + } else if ((event.metaKey || event.ctrlKey) && event.key === "k") { event.preventDefault(); openSearch(); } @@ -107,7 +115,7 @@ export const TopNav = (props) => { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, []); + }, [openSearch]); useEffect(() => { if (settings.sortOrder) { @@ -115,10 +123,6 @@ export const TopNav = (props) => { } }, [settings.sortOrder]); - const openSearch = () => { - searchDialog.handleOpen(); - }; - // Use the sorted bookmarks if sorting is applied, otherwise use the bookmarks in their current order const displayBookmarks = settings.bookmarks || []; @@ -169,7 +173,7 @@ export const TopNav = (props) => { > - {!mdDown && } + {!mdDown && } {mdDown && ( From 18f8d25c03a6e014cbd95b7e12454df5dc8ce79c Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:36:03 +0100 Subject: [PATCH 28/64] feat: add assignment filter options to application assignments --- src/pages/endpoint/applications/list/index.js | 91 ++++++++++++++++--- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 0405962e188b..49e5bc4b2816 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -20,6 +20,11 @@ const assignmentModeOptions = [ { label: "Append to existing assignments", value: "append" }, ]; +const assignmentFilterTypeOptions = [ + { label: "Include - Apply to devices matching filter", value: "include" }, + { label: "Exclude - Apply to devices NOT matching filter", value: "exclude" }, +]; + const getAppAssignmentSettingsType = (odataType) => { if (!odataType || typeof odataType !== "string") { return undefined; @@ -33,15 +38,35 @@ const Page = () => { const syncDialog = useDialog(); const tenant = useSettings().currentTenant; + const getAssignmentFilterFields = () => [ + { + type: "autoComplete", + name: "assignmentFilter", + label: "Assignment Filter (Optional)", + multiple: false, + creatable: false, + api: { + url: "/api/ListAssignmentFilters", + queryKey: `ListAssignmentFilters-${tenant}`, + labelField: (filter) => filter.displayName, + valueField: "displayName", + }, + }, + { + type: "radio", + name: "assignmentFilterType", + label: "Assignment Filter Mode", + options: assignmentFilterTypeOptions, + defaultValue: "include", + helperText: "Choose whether to include or exclude devices matching the filter.", + }, + ]; + const actions = [ { label: "Assign to All Users", type: "POST", url: "/api/ExecAssignApp", - data: { - AssignTo: "!AllUsers", - ID: "id", - }, fields: [ { type: "radio", @@ -62,7 +87,22 @@ const Page = () => { 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, + }; + }, confirmText: 'Are you sure you want to assign "[displayName]" to all users?', icon: , color: "info", @@ -71,10 +111,6 @@ const Page = () => { label: "Assign to All Devices", type: "POST", url: "/api/ExecAssignApp", - data: { - AssignTo: "!AllDevices", - ID: "id", - }, fields: [ { type: "radio", @@ -95,7 +131,22 @@ const Page = () => { 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, + }; + }, confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', icon: , color: "info", @@ -104,10 +155,6 @@ const Page = () => { label: "Assign Globally (All Users / All Devices)", type: "POST", url: "/api/ExecAssignApp", - data: { - AssignTo: "!AllDevicesAndUsers", - ID: "id", - }, fields: [ { type: "radio", @@ -128,7 +175,22 @@ const Page = () => { 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, + }; + }, confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', icon: , color: "info", @@ -188,6 +250,7 @@ const Page = () => { 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 selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; @@ -200,6 +263,10 @@ const Page = () => { 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, }; }, }, From 03dc06d7223da4299775aa7f146d5a3c44c19890 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:42:42 +0100 Subject: [PATCH 29/64] feat: add button to deploy group template on the groups page --- src/pages/identity/administration/groups/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/identity/administration/groups/index.js b/src/pages/identity/administration/groups/index.js index 1a05309ee7d2..3320ac353204 100644 --- a/src/pages/identity/administration/groups/index.js +++ b/src/pages/identity/administration/groups/index.js @@ -5,13 +5,13 @@ import Link from "next/link"; import { TrashIcon, EyeIcon } from "@heroicons/react/24/outline"; import { Visibility, - VisibilityOff, GroupAdd, Edit, LockOpen, Lock, GroupSharp, CloudSync, + RocketLaunch, } from "@mui/icons-material"; import { Stack } from "@mui/system"; import { useState } from "react"; @@ -313,6 +313,13 @@ const Page = () => { + } apiUrl="/api/ListGroups" From b433dd11ab032991ec1a67196f47e17b0bfab78d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:43:59 +0000 Subject: [PATCH 30/64] Bump react-hook-form from 7.71.1 to 7.71.2 Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.71.1 to 7.71.2. - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.71.1...v7.71.2) --- updated-dependencies: - dependency-name: react-hook-form dependency-version: 7.71.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 11957bf4dc59..127da5dca784 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "react-dropzone": "14.3.8", "react-error-boundary": "^6.1.0", "react-grid-layout": "^1.5.0", - "react-hook-form": "^7.71.1", + "react-hook-form": "^7.71.2", "react-hot-toast": "2.6.0", "react-html-parser": "^2.0.2", "react-i18next": "16.2.4", diff --git a/yarn.lock b/yarn.lock index 33f7f0fb0628..e4bbf7f4c626 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6734,10 +6734,10 @@ react-grid-layout@^1.5.0: react-resizable "^3.0.5" resize-observer-polyfill "^1.5.1" -react-hook-form@^7.71.1: - version "7.71.1" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.1.tgz#6a758958861682cf0eb22131eead684ba3618f66" - integrity sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w== +react-hook-form@^7.71.2: + version "7.71.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.2.tgz#a5f1d2b855be9ecf1af6e74df9b80f54beae7e35" + integrity sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA== react-hot-toast@2.6.0: version "2.6.0" From de8c5fc772dc7aba087c5121eb5f9b7d29e07d78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:44:55 +0000 Subject: [PATCH 31/64] Bump react from 19.2.3 to 19.2.4 Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.3 to 19.2.4. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react) --- updated-dependencies: - dependency-name: react dependency-version: 19.2.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 11957bf4dc59..a0310e77ef05 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "numeral": "2.0.6", "prop-types": "15.8.1", "punycode": "^2.3.1", - "react": "19.2.3", + "react": "19.2.4", "react-apexcharts": "1.7.0", "react-beautiful-dnd": "13.1.1", "react-copy-to-clipboard": "^5.1.0", diff --git a/yarn.lock b/yarn.lock index 33f7f0fb0628..300433a19ad0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6908,10 +6908,10 @@ react-window@^2.2.5: resolved "https://registry.yarnpkg.com/react-window/-/react-window-2.2.5.tgz#425a29609980083aafd5a48a1711a2af9319c1d2" integrity sha512-6viWvPSZvVuMIe9hrl4IIZoVfO/npiqOb03m4Z9w+VihmVzBbiudUrtUqDpsWdKvd/Ai31TCR25CBcFFAUm28w== -react@19.2.3: - version "19.2.3" - resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8" - integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== +react@19.2.4: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== readable-stream@^2.0.2: version "2.3.8" From 0aeb4451e88ac6de2aef9366f46b8c74672d2be8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:45:44 +0000 Subject: [PATCH 32/64] Bump LanceMcCarthy/Action-AzureBlobUpload from 3.7.0 to 3.8.0 Bumps [LanceMcCarthy/Action-AzureBlobUpload](https://github.com/lancemccarthy/action-azureblobupload) from 3.7.0 to 3.8.0. - [Release notes](https://github.com/lancemccarthy/action-azureblobupload/releases) - [Commits](https://github.com/lancemccarthy/action-azureblobupload/compare/v3.7.0...v3.8.0) --- updated-dependencies: - dependency-name: LanceMcCarthy/Action-AzureBlobUpload dependency-version: 3.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/cipp_dev_build.yml | 2 +- .github/workflows/cipp_frontend_build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cipp_dev_build.yml b/.github/workflows/cipp_dev_build.yml index 432d9363cade..ee28a418e72c 100644 --- a/.github/workflows/cipp_dev_build.yml +++ b/.github/workflows/cipp_dev_build.yml @@ -47,7 +47,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.7.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.8.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp diff --git a/.github/workflows/cipp_frontend_build.yml b/.github/workflows/cipp_frontend_build.yml index 5db059b438a8..d7c549442d89 100644 --- a/.github/workflows/cipp_frontend_build.yml +++ b/.github/workflows/cipp_frontend_build.yml @@ -47,7 +47,7 @@ jobs: # Upload to Azure Blob Storage - name: Azure Blob Upload - uses: LanceMcCarthy/Action-AzureBlobUpload@v3.7.0 + uses: LanceMcCarthy/Action-AzureBlobUpload@v3.8.0 with: connection_string: ${{ secrets.AZURE_CONNECTION_STRING }} container_name: cipp From 8cb96ab4488d0d0f1b049cdbea7d6ac0a4ebe104 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:45:49 +0000 Subject: [PATCH 33/64] Bump actions/setup-node from 6.2.0 to 6.3.0 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.2.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6.2.0...v6.3.0) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/Node_Project_Check.yml | 2 +- .github/workflows/cipp_dev_build.yml | 2 +- .github/workflows/cipp_frontend_build.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/Node_Project_Check.yml b/.github/workflows/Node_Project_Check.yml index a1581e036833..1116a307ceb7 100644 --- a/.github/workflows/Node_Project_Check.yml +++ b/.github/workflows/Node_Project_Check.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ matrix.node-version }} - name: Install and Build Test diff --git a/.github/workflows/cipp_dev_build.yml b/.github/workflows/cipp_dev_build.yml index 432d9363cade..44e0b9b591a8 100644 --- a/.github/workflows/cipp_dev_build.yml +++ b/.github/workflows/cipp_dev_build.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$node_sanitized_version" >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ steps.get_node_version.outputs.node_version }} diff --git a/.github/workflows/cipp_frontend_build.yml b/.github/workflows/cipp_frontend_build.yml index 5db059b438a8..af84a0196602 100644 --- a/.github/workflows/cipp_frontend_build.yml +++ b/.github/workflows/cipp_frontend_build.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$node_sanitized_version" >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v6.2.0 + uses: actions/setup-node@v6.3.0 with: node-version: ${{ steps.get_node_version.outputs.node_version }} From 1b9bb8fc0344da9f68cf534cf4b1b0488d5d5c4d Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:51:10 +0100 Subject: [PATCH 34/64] feat: update button link to new "add a tenant" wizard --- src/pages/tenant/gdap-management/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tenant/gdap-management/index.js b/src/pages/tenant/gdap-management/index.js index 915caff047a0..9aafd4a40531 100644 --- a/src/pages/tenant/gdap-management/index.js +++ b/src/pages/tenant/gdap-management/index.js @@ -167,7 +167,7 @@ const Page = () => { - - - - } - > - - - Vacation mode adds scheduled tasks to add and remove users from Conditional Access (CA) - exclusions for a specific period of time. Select the CA policy and the date range. If - the CA policy targets a named location, you now have the ability to exclude the targeted - users from location-based audit log alerts. - - - Note: Vacation mode has recently been updated to use Group based exclusions for better - reliability. Existing vacation mode entries will continue to function as before, but it - is recommended to recreate them to take advantage of the new functionality. The - exclusion group follows the format: 'Vacation Exclusion - $Policy.displayName' - - - - - - - - - - {/* User Selector */} - - - - - {/* Conditional Access Policy Selector */} - - `${option.displayName}`, - valueField: "id", - showRefresh: true, - } - : null - } - multiple={false} - formControl={formControl} - validators={{ - validate: (option) => { - if (!option?.value) { - return "Picking a policy is required"; - } - return true; - }, - }} - required={true} - disabled={!tenantDomain} - /> - - - {/* Start Date Picker */} - - { - if (!value) { - return "Start date is required"; - } - return true; - }, - }} - /> - - - {/* End Date Picker */} - - { - const startDate = formControl.getValues("startDate"); - if (!value) { - return "End date is required"; - } - if (startDate && value && new Date(value * 1000) < new Date(startDate * 1000)) { - return "End date must be after start date"; - } - return true; - }, - }} - /> - - - {/* Post Execution Actions */} - - - - - - - - {policyHasLocationTarget && ( - - - - )} - - policy.id === selectedPolicy?.value - )[0] || {} - } - title="Selected Policy JSON" - /> - - - - - - ); -}; diff --git a/src/components/CippWizard/CippWizardVacationActions.jsx b/src/components/CippWizard/CippWizardVacationActions.jsx new file mode 100644 index 000000000000..c7376a1528da --- /dev/null +++ b/src/components/CippWizard/CippWizardVacationActions.jsx @@ -0,0 +1,378 @@ +import { useEffect } from "react"; +import { Alert, Skeleton, Stack, Typography, Card, CardContent, CardHeader, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import CippWizardStepButtons from "./CippWizardStepButtons"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; +import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; +import { useWatch } from "react-hook-form"; +import { ApiGetCall } from "../../api/ApiCall"; + +export const CippWizardVacationActions = (props) => { + const { postUrl, formControl, onPreviousStep, onNextStep, currentStep, lastStep } = props; + + const currentTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); + const tenantDomain = currentTenant?.value || currentTenant; + + const enableCA = useWatch({ control: formControl.control, name: "enableCAExclusion" }); + const enableMailbox = useWatch({ control: formControl.control, name: "enableMailboxPermissions" }); + const enableOOO = useWatch({ control: formControl.control, name: "enableOOO" }); + const atLeastOneEnabled = enableCA || enableMailbox || enableOOO; + + const users = useWatch({ control: formControl.control, name: "Users" }); + const firstUser = Array.isArray(users) && users.length > 0 ? users[0] : null; + const firstUserUpn = firstUser?.addedFields?.userPrincipalName || firstUser?.value || null; + + const oooData = ApiGetCall({ + url: "/api/ListOoO", + data: { UserId: firstUserUpn, tenantFilter: tenantDomain }, + queryKey: `OOO-${firstUserUpn}-${tenantDomain}`, + waiting: !!(enableOOO && firstUserUpn && tenantDomain), + }); + + const isFetchingOOO = oooData.isFetching; + + useEffect(() => { + if (oooData.isSuccess && oooData.data) { + const currentInternal = formControl.getValues("oooInternalMessage"); + const currentExternal = formControl.getValues("oooExternalMessage"); + if (!currentInternal) { + formControl.setValue("oooInternalMessage", oooData.data.InternalMessage || ""); + } + if (!currentExternal) { + formControl.setValue("oooExternalMessage", oooData.data.ExternalMessage || ""); + } + } + }, [oooData.isSuccess, oooData.data]); + + return ( + + + {/* CA Policy Exclusion Section */} + + + + + + + + + + + + Vacation mode uses group-based exclusions for reliability. The exclusion group + follows the format: 'Vacation Exclusion - $Policy.displayName' + + + + `${option.displayName}`, + valueField: "id", + showRefresh: true, + } + : null + } + multiple={false} + formControl={formControl} + validators={{ + validate: (option) => { + if (!option?.value) { + return "Picking a policy is required"; + } + return true; + }, + }} + required={true} + disabled={!tenantDomain} + /> + + + + + + + + + + + {/* Mailbox Permissions Section */} + + + + + + + + + + + + Grant temporary mailbox permissions (Full Access, Send As, Send On Behalf) and + optional calendar access to delegates. Permissions are automatically added at the + start date and removed at the end date. + + + + {/* Delegate(s) */} + + + + + {/* Permission Types */} + + + + + {/* AutoMap (visible when FullAccess is selected) */} + + + + + + + {/* Include Calendar Permissions */} + + + + + {/* Calendar permission details */} + + + { + if (!option?.value) { + return "Calendar permission level is required"; + } + return true; + }, + }} + required={true} + /> + + + + + + + + + + + + + + {/* Out of Office Section */} + + + + + + + + + + + + Out of office will be enabled with the messages below at the start date and + automatically disabled at the end date. The disable task preserves any message + updates the user may have made during their vacation. + + + + {isFetchingOOO ? ( + <> + + Internal Message + + + + ) : ( + + )} + + + {isFetchingOOO ? ( + <> + + External Message (optional) + + + + ) : ( + + )} + + + + + + + + + + ); +}; diff --git a/src/components/CippWizard/CippWizardVacationConfirmation.jsx b/src/components/CippWizard/CippWizardVacationConfirmation.jsx new file mode 100644 index 000000000000..da86c414feb0 --- /dev/null +++ b/src/components/CippWizard/CippWizardVacationConfirmation.jsx @@ -0,0 +1,290 @@ +import { Alert, Button, Card, CardContent, CardHeader, Chip, Divider, Stack, Typography } from "@mui/material"; +import { Grid } from "@mui/system"; +import { CippWizardStepButtons } from "./CippWizardStepButtons"; +import { CippApiResults } from "../CippComponents/CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; +import { useWatch } from "react-hook-form"; +import Link from "next/link"; + +export const CippWizardVacationConfirmation = (props) => { + const { formControl, onPreviousStep, currentStep, lastStep } = props; + + const values = useWatch({ control: formControl.control }); + + const caExclusion = ApiPostCall({ relatedQueryKeys: ["VacationMode"] }); + const mailboxVacation = ApiPostCall({ relatedQueryKeys: ["VacationMode"] }); + const oooVacation = ApiPostCall({ relatedQueryKeys: ["VacationMode"] }); + + const tenantFilter = values.tenantFilter?.value || values.tenantFilter; + const isSubmitting = caExclusion.isPending || mailboxVacation.isPending || oooVacation.isPending; + const hasSubmitted = caExclusion.isSuccess || mailboxVacation.isSuccess || oooVacation.isSuccess; + + const handleSubmit = () => { + if (values.enableCAExclusion) { + caExclusion.mutate({ + url: "/api/ExecCAExclusion", + data: { + tenantFilter, + Users: values.Users, + PolicyId: values.PolicyId?.value, + StartDate: values.startDate, + EndDate: values.endDate, + vacation: true, + reference: values.reference || null, + postExecution: values.postExecution || [], + excludeLocationAuditAlerts: values.excludeLocationAuditAlerts || false, + }, + }); + } + + if (values.enableMailboxPermissions) { + mailboxVacation.mutate({ + url: "/api/ExecScheduleMailboxVacation", + data: { + tenantFilter, + mailboxOwners: values.Users, + delegates: values.delegates, + permissionTypes: values.permissionTypes, + autoMap: values.autoMap, + includeCalendar: values.includeCalendar, + calendarPermission: values.calendarPermission, + canViewPrivateItems: values.canViewPrivateItems, + startDate: values.startDate, + endDate: values.endDate, + reference: values.reference || null, + postExecution: values.postExecution || [], + }, + }); + } + + if (values.enableOOO) { + 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 || [], + }, + }); + } + }; + + const formatDate = (epoch) => { + if (!epoch) return "Not set"; + return new Date(epoch * 1000).toLocaleString(); + }; + + const formatUsers = (users) => { + if (!users || users.length === 0) return "None"; + return users.map((u) => u.label || u.value || u).join(", "); + }; + + return ( + + {/* Summary */} + + + + + + {/* General Info */} + + + Tenant + + {tenantFilter || "Not selected"} + + + + + Users Going on Vacation + + {formatUsers(values.Users)} + + + + + Start Date + + {formatDate(values.startDate)} + + + + + End Date + + {formatDate(values.endDate)} + + + {values.reference && ( + + + Reference + + {values.reference} + + )} + + + + + {/* Enabled Actions */} + {(() => { + const enabledCount = [values.enableCAExclusion, values.enableMailboxPermissions, values.enableOOO].filter(Boolean).length; + const mdSize = enabledCount >= 3 ? 4 : enabledCount === 2 ? 6 : 12; + return ( + + {values.enableCAExclusion && ( + + + } + /> + + + +
    + + Policy + + + {values.PolicyId?.label || "Not selected"} + +
    + {values.excludeLocationAuditAlerts && ( +
    + + Location-based audit log alerts will be excluded + +
    + )} +
    +
    +
    +
    + )} + + {values.enableMailboxPermissions && ( + + + } + /> + + + +
    + + Delegates + + {formatUsers(values.delegates)} +
    +
    + + Permission Types + + + {(values.permissionTypes || []).map((p) => p.label || p.value).join(", ") || + "None"} + +
    + {values.includeCalendar && ( +
    + + Calendar + + + {values.calendarPermission?.label || "Not set"} + {values.canViewPrivateItems ? " (Can view private items)" : ""} + +
    + )} +
    +
    +
    +
    + )} + + {values.enableOOO && ( + + + } + /> + + + +
    + + Internal Message + + + {values.oooInternalMessage + ? String(values.oooInternalMessage).replace(/[<>]/g, "").slice(0, 120) + + (String(values.oooInternalMessage).replace(/[<>]/g, "").length > 120 ? "…" : "") + : "Not set"} + +
    + {values.oooExternalMessage && ( +
    + + External Message + + + {String(values.oooExternalMessage).replace(/[<>]/g, "").slice(0, 120) + + (String(values.oooExternalMessage).replace(/[<>]/g, "").length > 120 ? "…" : "")} + +
    + )} +
    +
    +
    +
    + )} +
    + ); + })()} + + {/* API Results */} + {values.enableCAExclusion && } + {values.enableMailboxPermissions && } + {values.enableOOO && } + + {/* Navigation + Custom Submit */} + + {currentStep > 0 && ( + + )} + {hasSubmitted ? ( + + ) : ( + + )} + +
    + ); +}; diff --git a/src/components/CippWizard/CippWizardVacationSchedule.jsx b/src/components/CippWizard/CippWizardVacationSchedule.jsx new file mode 100644 index 000000000000..719f194a6d07 --- /dev/null +++ b/src/components/CippWizard/CippWizardVacationSchedule.jsx @@ -0,0 +1,98 @@ +import { Stack, Typography } from "@mui/material"; +import { Grid } from "@mui/system"; +import CippWizardStepButtons from "./CippWizardStepButtons"; +import CippFormComponent from "../CippComponents/CippFormComponent"; + +export const CippWizardVacationSchedule = (props) => { + const { postUrl, formControl, onPreviousStep, onNextStep, currentStep, lastStep } = props; + + return ( + + + Set the date range for the vacation period and optional notification settings. + + + + {/* Start Date */} + + { + if (!value) { + return "Start date is required"; + } + return true; + }, + }} + /> + + + {/* End Date */} + + { + const startDate = formControl.getValues("startDate"); + if (!value) { + return "End date is required"; + } + if (startDate && value && new Date(value * 1000) < new Date(startDate * 1000)) { + return "End date must be after start date"; + } + return true; + }, + }} + /> + + + {/* Post Execution Actions */} + + + + + {/* Reference */} + + + + + + + + ); +}; diff --git a/src/layouts/config.js b/src/layouts/config.js index fa8c563f9830..78a9cd4ed9d5 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -78,6 +78,11 @@ export const nativeMenuItems = [ path: "/identity/administration/jit-admin-templates", permissions: ["Identity.Role.*"], }, + { + title: "Vacation Mode", + path: "/identity/administration/vacation-mode", + permissions: ["Identity.User.*"], + }, { title: "Offboarding Wizard", path: "/identity/administration/offboarding-wizard", diff --git a/src/pages/identity/administration/vacation-mode/add/index.js b/src/pages/identity/administration/vacation-mode/add/index.js new file mode 100644 index 000000000000..eb4f73a75cbc --- /dev/null +++ b/src/pages/identity/administration/vacation-mode/add/index.js @@ -0,0 +1,99 @@ +import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; +import CippWizardPage from "../../../../../components/CippWizard/CippWizardPage.jsx"; +import { CippTenantStep } from "../../../../../components/CippWizard/CippTenantStep.jsx"; +import { CippWizardAutoComplete } from "../../../../../components/CippWizard/CippWizardAutoComplete"; +import { CippWizardVacationActions } from "../../../../../components/CippWizard/CippWizardVacationActions"; +import { CippWizardVacationSchedule } from "../../../../../components/CippWizard/CippWizardVacationSchedule"; +import { CippWizardVacationConfirmation } from "../../../../../components/CippWizard/CippWizardVacationConfirmation"; + +const Page = () => { + const steps = [ + { + title: "Step 1", + description: "Tenant Selection", + component: CippTenantStep, + componentProps: { + allTenants: false, + type: "single", + }, + }, + { + title: "Step 2", + description: "User Selection", + component: CippWizardAutoComplete, + componentProps: { + title: "Select the users to apply vacation mode for", + name: "Users", + placeholder: "Select Users", + type: "multiple", + api: { + url: "/api/ListGraphRequest", + dataKey: "Results", + queryKey: "Users - {tenant}", + data: { + Endpoint: "users", + manualPagination: true, + $select: "id,userPrincipalName,displayName", + $count: true, + $orderby: "displayName", + $top: 999, + }, + addedField: { + userPrincipalName: "userPrincipalName", + }, + labelField: (option) => `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + }, + }, + }, + { + title: "Step 3", + description: "Vacation Actions", + component: CippWizardVacationActions, + }, + { + title: "Step 4", + description: "Schedule", + component: CippWizardVacationSchedule, + }, + { + title: "Step 5", + description: "Review & Submit", + component: CippWizardVacationConfirmation, + }, + ]; + + const initialState = { + tenantFilter: null, + Users: [], + enableCAExclusion: false, + PolicyId: null, + excludeLocationAuditAlerts: false, + enableMailboxPermissions: false, + delegates: [], + permissionTypes: [], + autoMap: true, + includeCalendar: false, + calendarPermission: null, + canViewPrivateItems: false, + enableOOO: false, + oooInternalMessage: null, + oooExternalMessage: null, + startDate: null, + endDate: null, + postExecution: [], + reference: null, + }; + + return ( + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/identity/administration/vacation-mode/index.js b/src/pages/identity/administration/vacation-mode/index.js new file mode 100644 index 000000000000..1b85952156e1 --- /dev/null +++ b/src/pages/identity/administration/vacation-mode/index.js @@ -0,0 +1,108 @@ +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import CippTablePage from "../../../../components/CippComponents/CippTablePage"; +import { Delete } from "@mui/icons-material"; +import { EyeIcon } from "@heroicons/react/24/outline"; +import { Button } from "@mui/material"; +import Link from "next/link"; +import { EventAvailable } from "@mui/icons-material"; + +const Page = () => { + const actions = [ + { + label: "View Task Details", + link: "/cipp/scheduler/task?id=[RowKey]", + icon: , + }, + { + label: "Cancel Vacation Mode", + type: "POST", + url: "/api/RemoveScheduledItem", + data: { ID: "RowKey" }, + confirmText: + "Are you sure you want to cancel this vacation mode entry? This might mean the user will remain in vacation mode permanently.", + icon: , + multiPost: false, + }, + ]; + + const filterList = [ + { + filterName: "Running", + value: [{ id: "TaskState", value: "Running" }], + type: "column", + }, + { + filterName: "Planned", + value: [{ id: "TaskState", value: "Planned" }], + type: "column", + }, + { + filterName: "Failed", + value: [{ id: "TaskState", value: "Failed" }], + type: "column", + }, + { + filterName: "Completed", + value: [{ id: "TaskState", value: "Completed" }], + type: "column", + }, + { + filterName: "CA Exclusion", + value: [{ id: "Name", value: "CA Exclusion" }], + type: "column", + }, + { + filterName: "Mailbox Permissions", + value: [{ id: "Name", value: "Mailbox Vacation" }], + type: "column", + }, + { + filterName: "Out of Office", + value: [{ id: "Name", value: "OOO Vacation" }], + type: "column", + }, + ]; + + return ( + } + > + Add Vacation Schedule + + } + title="Vacation Mode" + apiUrl="/api/ListScheduledItems?SearchTitle=*Vacation*" + queryKey="VacationMode" + tenantInTitle={false} + actions={actions} + simpleColumns={[ + "Tenant", + "Name", + "Reference", + "TaskState", + "ScheduledTime", + "ExecutedTime", + ]} + filters={filterList} + offCanvas={{ + extendedInfoFields: [ + "Name", + "TaskState", + "ScheduledTime", + "Reference", + "Tenant", + "ExecutedTime", + ], + actions: actions, + }} + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/tenant/conditional/deploy-vacation/index.js b/src/pages/tenant/conditional/deploy-vacation/index.js index 14034536f6a5..cce34f8d2dc7 100644 --- a/src/pages/tenant/conditional/deploy-vacation/index.js +++ b/src/pages/tenant/conditional/deploy-vacation/index.js @@ -1,87 +1,22 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; -import CippTablePage from "../../../../components/CippComponents/CippTablePage"; -import { Delete } from "@mui/icons-material"; -import { EyeIcon } from "@heroicons/react/24/outline"; -import { CippAddVacationModeDrawer } from "../../../../components/CippComponents/CippAddVacationModeDrawer"; +import { Alert, Box, Button } from "@mui/material"; +import Link from "next/link"; const Page = () => { - const actions = [ - { - label: "View Task Details", - link: "/cipp/scheduler/task?id=[RowKey]", - icon: , - }, - { - label: "Cancel Vacation Mode", - type: "POST", - url: "/api/RemoveScheduledItem", - data: { ID: "RowKey" }, - confirmText: - "Are you sure you want to cancel this vacation mode entry? This might mean the user will remain in vacation mode permanently.", - icon: , - multiPost: false, - }, - ]; - - const filterList = [ - { - filterName: "Running", - value: [{ id: "TaskState", value: "Running" }], - type: "column", - }, - { - filterName: "Planned", - value: [{ id: "TaskState", value: "Planned" }], - type: "column", - }, - { - filterName: "Failed", - value: [{ id: "TaskState", value: "Failed" }], - type: "column", - }, - { - filterName: "Completed", - value: [{ id: "TaskState", value: "Completed" }], - type: "column", - }, - ]; - return ( - - - - } - title="Vacation Mode" - apiUrl="/api/ListScheduledItems?SearchTitle=*CA Exclusion Vacation*" - queryKey="VacationMode" - tenantInTitle={false} - actions={actions} - simpleColumns={[ - "Tenant", - "Name", - "Parameters.Member", - "Reference", - "TaskState", - "ScheduledTime", - "ExecutedTime", - ]} - filters={filterList} - offCanvas={{ - extendedInfoFields: [ - "Name", - "TaskState", - "ScheduledTime", - "Parameters.Member", - "Reference", - "Parameters.PolicyId", - "Tenant", - "ExecutedTime", - ], - actions: actions, - }} - /> + + + Vacation Mode has moved to{" "} + Identity Management → Administration → Vacation Mode. + + + ); }; From 65b72c539c7abc99e8c238a15f6d66f58a52e955 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:02:58 +0100 Subject: [PATCH 43/64] feat: add validation to user forms and update form modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented field validators for various fields in CippAddEditUser, CippBulkUserDrawer, CippInviteGuestDrawer, and CippBulkInviteGuestDrawer components. - Changed form control mode from "onChange" to "onBlur" for better user experience. - CippFormComponent: wire field.onBlur for all Controller-based field types (textField, textFieldWithVariables, radio, select, autoComplete, datePicker, file) — without this, onBlur validation mode never triggered for any of these types --- .../CippBulkInviteGuestDrawer.jsx | 7 +++- .../CippComponents/CippBulkUserDrawer.jsx | 35 ++++++++++++++-- .../CippComponents/CippFormComponent.jsx | 8 ++++ .../CippComponents/CippInviteGuestDrawer.jsx | 8 ++-- .../CippFormPages/CippAddEditUser.jsx | 41 +++++++++++++++++++ 5 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx b/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx index f09d8d7bd1a7..d9d413888246 100644 --- a/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx +++ b/src/components/CippComponents/CippBulkInviteGuestDrawer.jsx @@ -9,6 +9,7 @@ import { CippDataTable } from "../CippTable/CippDataTable"; import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; import { ApiPostCall } from "../../api/ApiCall"; +import { getCippValidator } from "../../utils/get-cipp-validator"; export const CippBulkInviteGuestDrawer = ({ buttonText = "Bulk Invite Guests", @@ -22,7 +23,7 @@ export const CippBulkInviteGuestDrawer = ({ const fields = ["displayName", "mail", "redirectUri"]; const formControl = useForm({ - mode: "onChange", + mode: "onBlur", defaultValues: { tenantFilter: initialState.currentTenant, sendInvite: true, @@ -255,6 +256,10 @@ export const CippBulkInviteGuestDrawer = ({ label="E-mail Address" type="textField" formControl={formControl} + validators={{ + required: "E-mail address is required", + validate: (value) => !value || getCippValidator(value, "email"), + }} />
    diff --git a/src/components/CippComponents/CippBulkUserDrawer.jsx b/src/components/CippComponents/CippBulkUserDrawer.jsx index 6fd62b106c38..53d4317c07be 100644 --- a/src/components/CippComponents/CippBulkUserDrawer.jsx +++ b/src/components/CippComponents/CippBulkUserDrawer.jsx @@ -43,8 +43,34 @@ export const CippBulkUserDrawer = ({ ...addedFields, ]; + const fieldValidators = { + givenName: { maxLength: { value: 64, message: "First Name cannot exceed 64 characters" } }, + surName: { maxLength: { value: 64, message: "Last Name cannot exceed 64 characters" } }, + displayName: { + required: "Display Name is required", + maxLength: { value: 256, message: "Display Name cannot exceed 256 characters" }, + }, + mailNickName: { + required: "Username is required", + maxLength: { value: 64, message: "Username cannot exceed 64 characters" }, + pattern: { + value: /^[A-Za-z0-9'.\-_!#^~]+$/, + message: "Username can only contain letters, numbers, and ' . - _ ! # ^ ~ characters", + }, + }, + JobTitle: { maxLength: { value: 128, message: "Job Title cannot exceed 128 characters" } }, + streetAddress: { + maxLength: { value: 1024, message: "Street Address cannot exceed 1024 characters" }, + }, + PostalCode: { maxLength: { value: 40, message: "Postal Code cannot exceed 40 characters" } }, + City: { maxLength: { value: 128, message: "City cannot exceed 128 characters" } }, + State: { maxLength: { value: 128, message: "State/Province cannot exceed 128 characters" } }, + Department: { maxLength: { value: 64, message: "Department cannot exceed 64 characters" } }, + MobilePhone: { maxLength: { value: 64, message: "Mobile # cannot exceed 64 characters" } }, + }; + const formControl = useForm({ - mode: "onChange", + mode: "onBlur", defaultValues: { tenantFilter: initialState.currentTenant, usageLocation: initialState.usageLocation || "US", @@ -141,8 +167,8 @@ export const CippBulkUserDrawer = ({ {createBulkUsers.isLoading ? "Creating Users..." : createBulkUsers.isSuccess - ? "Create More Users" - : "Create Users"} + ? "Create More Users" + : "Create Users"} + + + + + + + {categories.map((cat) => { + const items = Array.isArray(backupData) + ? backupData.filter((item) => getItemCategoryKey(item) === cat.key) + : []; + const isExpanded = !!expandedCategories[cat.key]; + return ( + + + handleToggleCategory(cat.key)} + size="small" + /> + } + label={ + + + {cat.label} + + + + } + /> + + handleToggleExpand(cat.key)}> + {isExpanded ? ( + + ) : ( + + )} + + + + + (theme.palette.mode === "dark" ? "grey.900" : "grey.50"), + borderRadius: 1, + border: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + {items.map((item, i) => { + const name = getItemDisplayName(item); + const sub = + name !== item.RowKey && item.RowKey + ? item.RowKey.replace(/\.json$/, "") + : null; + return ( + + + {name} + + {sub && ( + + {sub} + + )} + + ); + })} + + + + ); + })} + + + + ); + + // Step 2 — Confirm and results + const StepConfirm = () => ( + + {!restoreAction.isSuccess && ( + }> + Confirm Restore + This will overwrite your current CIPP configuration for the selected categories. This + action cannot be undone. + + )} + + + {selectedCount} {selectedCount === 1 ? "category" : "categories"} ({filteredData.length}{" "} + items) selected for restore: + + + {categories + .filter((c) => selectedCategories[c.key]) + .map((c) => ( + + ))} + + + + + ); + + const StepComponents = [StepValidation, StepSelectCategories, StepConfirm]; + const CurrentStep = StepComponents[step]; + + const canProceed = + step === 0 ? validationResult?.isValid : step === 1 ? selectedCount > 0 : false; + + return ( + + + + Restore Backup + + + {isLoading ? ( + + + + Loading backup… + + + ) : ( + + + {WIZARD_STEPS.map((label) => ( + + {label} + + ))} + + + + + )} + + + + {!isLoading && step > 0 && !restoreAction.isSuccess && ( + + )} + {!isLoading && step < WIZARD_STEPS.length - 1 && ( + + )} + {!isLoading && step === WIZARD_STEPS.length - 1 && !restoreAction.isSuccess && ( + + )} + + + ); +}; + +export default CippRestoreWizard; diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index 6de4144029b6..3921e8ab9a0b 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -5,8 +5,6 @@ import { Stack, Typography, Skeleton, - Alert, - AlertTitle, Input, FormControl, FormLabel, @@ -25,24 +23,22 @@ import { NextPlan, SettingsBackupRestore, Storage, - Warning, - CheckCircle, - Error as ErrorIcon, - UploadFile, } from "@mui/icons-material"; import ReactTimeAgo from "react-time-ago"; import { CippDataTable } from "../../../components/CippTable/CippDataTable"; import { CippApiResults } from "../../../components/CippComponents/CippApiResults"; -import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; -import { BackupValidator, BackupValidationError } from "../../../utils/backupValidation"; +import { CippRestoreWizard } from "../../../components/CippComponents/CippRestoreWizard"; +import { BackupValidator } from "../../../utils/backupValidation"; import { useState } from "react"; import { useDialog } from "../../../hooks/use-dialog"; const Page = () => { const [validationResult, setValidationResult] = useState(null); - const restoreDialog = useDialog(); + const wizardDialog = useDialog(); const [selectedBackupFile, setSelectedBackupFile] = useState(null); const [selectedBackupData, setSelectedBackupData] = useState(null); + const [selectedBackupName, setSelectedBackupName] = useState(null); + const [wizardLoading, setWizardLoading] = useState(false); const backupList = ApiGetCall({ url: "/api/ExecListBackup", @@ -68,6 +64,10 @@ const Page = () => { urlFromData: true, }); + const fetchForRestore = ApiPostCall({ + urlFromData: true, + }); + const runBackup = ApiPostCall({ urlFromData: true, relatedQueryKeys: ["BackupList", "ScheduledBackup"], @@ -78,80 +78,6 @@ const Page = () => { relatedQueryKeys: ["ScheduledBackup"], }); - // Component for displaying validation results - const ValidationResultsDisplay = ({ result }) => { - if (!result) return null; - - return ( - - {result.isValid ? ( - }> - Backup Validation Successful - - The backup file is valid and ready for restoration. - - {result.validRows !== undefined && result.totalRows !== undefined && ( - - Import Summary: {result.validRows} valid rows out of{" "} - {result.totalRows} total rows will be imported. - - )} - {result.repaired && ( - - Note: The backup file had minor issues that were automatically - repaired. - - )} - {result.warnings.length > 0 && ( - - - Warnings: - -
      - {result.warnings.map((warning, index) => ( -
    • - - {warning} - -
    • - ))} -
    -
    - )} -
    - ) : ( - }> - Backup Validation Failed - - The backup file is corrupted and cannot be restored safely. - - {result.validRows !== undefined && result.totalRows !== undefined && ( - - Analysis: Found {result.validRows} valid rows out of{" "} - {result.totalRows} total rows. - - )} - - - Errors found: - -
      - {result.errors.map((error, index) => ( -
    • - {error} -
    • - ))} -
    -
    - - Please try downloading a fresh backup or contact support if this issue persists. - -
    - )} -
    - ); - }; - const NextBackupRun = (props) => { const date = new Date(props.date); if (isNaN(date)) { @@ -177,58 +103,81 @@ const Page = () => { }); }; + const openWizardWithData = ({ file, validation, data, backupName = null }) => { + setValidationResult(validation); + setSelectedBackupFile(file); + setSelectedBackupData(validation.isValid && data ? data : null); + setSelectedBackupName(backupName); + wizardDialog.handleOpen(); + }; + const handleRestoreBackupUpload = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); - reader.onload = (e) => { + reader.onload = (evt) => { try { - const rawContent = e.target.result; - - // Validate the backup file + const rawContent = evt.target.result; const validation = BackupValidator.validateBackup(rawContent); - setValidationResult(validation); - - // Store the file info and validated data - setSelectedBackupFile({ - name: file.name, - size: file.size, - lastModified: new Date(file.lastModified), + openWizardWithData({ + file: { name: file.name, size: file.size, lastModified: new Date(file.lastModified) }, + validation, + data: validation.data, }); - - if (validation.isValid) { - setSelectedBackupData(validation.data); - } else { - setSelectedBackupData(null); - } - - // Open the confirmation dialog - restoreDialog.handleOpen(); - - // Clear the file input - e.target.value = null; } catch (error) { console.error("Backup validation error:", error); - setValidationResult({ - isValid: false, - errors: [`Validation failed: ${error.message}`], - warnings: [], - repaired: false, - }); - setSelectedBackupFile({ - name: file.name, - size: file.size, - lastModified: new Date(file.lastModified), + openWizardWithData({ + file: { name: file.name, size: file.size, lastModified: new Date(file.lastModified) }, + validation: { + isValid: false, + errors: [`Validation failed: ${error.message}`], + warnings: [], + repaired: false, + }, + data: null, }); - setSelectedBackupData(null); - restoreDialog.handleOpen(); - e.target.value = null; } + // Clear file input + e.target.value = null; }; reader.readAsText(file); }; + const handleTableRestoreAction = (row) => { + // Open immediately with loading state + setValidationResult(null); + setSelectedBackupFile({ + name: row.BackupName, + size: null, + lastModified: row.Timestamp ? new Date(row.Timestamp) : null, + }); + setSelectedBackupData(null); + setSelectedBackupName(row.BackupName); + setWizardLoading(true); + wizardDialog.handleOpen(); + fetchForRestore.mutate( + { + url: `/api/ExecListBackup?BackupName=${row.BackupName}`, + data: {}, + }, + { + onSuccess: (data) => { + const jsonString = data?.data?.[0]?.Backup; + if (!jsonString) { + setWizardLoading(false); + return; + } + const validation = BackupValidator.validateBackup(jsonString); + setValidationResult(validation); + setSelectedBackupData(validation.isValid && validation.data ? validation.data : null); + setWizardLoading(false); + }, + onError: () => setWizardLoading(false), + }, + ); + }; + const handleDownloadBackupAction = (row) => { downloadAction.mutate( { @@ -245,30 +194,10 @@ const Page = () => { // Validate the backup before downloading const validation = BackupValidator.validateBackup(jsonString); - let finalJsonString = jsonString; + let downloadContent = jsonString; if (validation.repaired) { // Use the repaired version if available - finalJsonString = JSON.stringify(validation.data, null, 2); - } - - // Create a validation report comment at the top - let downloadContent = finalJsonString; - if (!validation.isValid || validation.warnings.length > 0) { - const report = { - validationReport: { - timestamp: new Date().toISOString(), - isValid: validation.isValid, - repaired: validation.repaired, - errors: validation.errors, - warnings: validation.warnings, - }, - }; - - downloadContent = `// CIPP Backup Validation Report\n// ${JSON.stringify( - report, - null, - 2 - )}\n\n${finalJsonString}`; + downloadContent = JSON.stringify(validation.data, null, 2); } const blob = new Blob([downloadContent], { type: "application/json" }); @@ -281,7 +210,7 @@ const Page = () => { document.body.removeChild(a); URL.revokeObjectURL(url); }, - } + }, ); }; @@ -289,12 +218,8 @@ const Page = () => { { label: "Restore Backup", icon: , - type: "POST", - url: "/api/ExecRestoreBackup", - data: { BackupName: "BackupName" }, - confirmText: "Are you sure you want to restore this backup?", - relatedQueryKeys: ["BackupList"], - multiPost: false, + noConfirm: true, + customFunction: handleTableRestoreAction, hideBulk: true, }, { @@ -419,140 +344,22 @@ const Page = () => { - {/* Backup Restore Confirmation Dialog */} - { - restoreDialog.handleClose(); - // Clear state when user manually closes the dialog - setValidationResult(null); - setSelectedBackupFile(null); - setSelectedBackupData(null); - }, - }} - api={{ - type: "POST", - url: "/api/ExecRestoreBackup", - customDataformatter: () => selectedBackupData, - confirmText: validationResult?.isValid - ? "Are you sure you want to restore this backup? This will overwrite your current CIPP configuration." - : null, - onSuccess: () => { - // Don't auto-close the dialog - let user see the results and close manually - // The dialog will show the API results and user can close when ready - }, + { + wizardDialog.handleClose(); + setValidationResult(null); + setSelectedBackupFile(null); + setSelectedBackupData(null); + setSelectedBackupName(null); + setWizardLoading(false); }} - relatedQueryKeys={["BackupList", "ScheduledBackup"]} - > - {({ formHook, row }) => ( - - {/* File Information */} - {selectedBackupFile && ( - - - - Selected File - - (theme.palette.mode === "dark" ? "grey.800" : "grey.50"), - borderRadius: 1, - border: (theme) => `1px solid ${theme.palette.divider}`, - }} - > - - - Filename: {selectedBackupFile.name} - - - Size: {(selectedBackupFile.size / 1024 / 1024).toFixed(2)} MB - - - Last Modified:{" "} - {selectedBackupFile.lastModified.toLocaleString()} - - - - - )} - - {/* Validation Results */} - - - {/* Additional Information if Validation Failed */} - {validationResult && !validationResult.isValid && ( - }> - Restore Blocked - The backup file cannot be restored due to validation errors. Please ensure you have - a valid backup file before proceeding. - - )} - - {/* Success Information with Data Summary */} - {validationResult?.isValid && selectedBackupData && ( - - - - Backup Contents - - - theme.palette.mode === "dark" ? "success.dark" : "success.light", - borderRadius: 1, - border: (theme) => `1px solid ${theme.palette.success.main}`, - color: (theme) => - theme.palette.mode === "dark" ? "success.contrastText" : "success.dark", - }} - > - - - Total Objects:{" "} - {Array.isArray(selectedBackupData) ? selectedBackupData.length : "Unknown"} - - {validationResult.repaired && ( - - Status: Automatically repaired and validated - - )} - {validationResult.warnings.length > 0 && ( - - Warnings: {validationResult.warnings.length} warning(s) - noted - - )} - - - - )} - - )} - + validationResult={validationResult} + backupFile={selectedBackupFile} + backupData={selectedBackupData} + backupName={selectedBackupName} + isLoading={wizardLoading} + /> ); }; From 74a4bc21bfd5bb8506a58e030b649df39bc0587d Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 7 Mar 2026 22:31:45 -0500 Subject: [PATCH 53/64] feat: enhance backup management with dialogs for running and scheduling backups --- src/pages/cipp/settings/backup.js | 187 ++++++++++++++++-------------- 1 file changed, 102 insertions(+), 85 deletions(-) diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index 3921e8ab9a0b..44434331e215 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -1,4 +1,5 @@ import { + Alert, Box, Button, CardContent, @@ -26,7 +27,7 @@ import { } from "@mui/icons-material"; import ReactTimeAgo from "react-time-ago"; import { CippDataTable } from "../../../components/CippTable/CippDataTable"; -import { CippApiResults } from "../../../components/CippComponents/CippApiResults"; +import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; import { CippRestoreWizard } from "../../../components/CippComponents/CippRestoreWizard"; import { BackupValidator } from "../../../utils/backupValidation"; import { useState } from "react"; @@ -35,6 +36,9 @@ import { useDialog } from "../../../hooks/use-dialog"; const Page = () => { const [validationResult, setValidationResult] = useState(null); const wizardDialog = useDialog(); + const runBackupDialog = useDialog(); + const enableBackupDialog = useDialog(); + const disableBackupDialog = useDialog(); const [selectedBackupFile, setSelectedBackupFile] = useState(null); const [selectedBackupData, setSelectedBackupData] = useState(null); const [selectedBackupName, setSelectedBackupName] = useState(null); @@ -56,10 +60,6 @@ const Page = () => { queryKey: "ScheduledBackup", }); - const backupAction = ApiPostCall({ - urlFromData: true, - }); - const downloadAction = ApiPostCall({ urlFromData: true, }); @@ -68,16 +68,6 @@ const Page = () => { urlFromData: true, }); - const runBackup = ApiPostCall({ - urlFromData: true, - relatedQueryKeys: ["BackupList", "ScheduledBackup"], - }); - - const enableBackupSchedule = ApiPostCall({ - urlFromData: true, - relatedQueryKeys: ["ScheduledBackup"], - }); - const NextBackupRun = (props) => { const date = new Date(props.date); if (isNaN(date)) { @@ -87,22 +77,6 @@ const Page = () => { } }; - const handleCreateBackup = () => { - runBackup.mutate({ - url: "/api/ExecRunBackup", - data: {}, - }); - }; - - const handleEnableScheduledBackup = () => { - enableBackupSchedule.mutate({ - url: "/api/ExecSetCIPPAutoBackup", - data: { - Enabled: true, - }, - }); - }; - const openWizardWithData = ({ file, validation, data, backupName = null }) => { setValidationResult(validation); setSelectedBackupFile(file); @@ -236,52 +210,50 @@ const Page = () => { title="CIPP Backup" backButtonTitle="Settings" infoBar={ - , - name: "Backup Count", - data: backupList.data?.length, - }, - { - icon: , - name: "Last Backup", - data: backupList.data?.[0]?.Timestamp ? ( - - ) : ( - "No Backups" - ), - }, - { - icon: , - name: "Automatic Backups", - data: - scheduledBackup.data?.[0]?.Name === "Automated CIPP Backup" - ? "Enabled" - : "Disabled", - }, - { - icon: , - name: "Next Backup", - data: , - }, - ]} - /> + + , + name: "Backup Count", + data: backupList.data?.length, + }, + { + icon: , + name: "Last Backup", + data: backupList.data?.[0]?.Timestamp ? ( + + ) : ( + "No Backups" + ), + }, + { + icon: , + name: "Automatic Backups", + data: + scheduledBackup.data?.[0]?.Name === "Automated CIPP Backup" + ? "Enabled" + : "Disabled", + }, + { + icon: , + name: "Next Backup", + data: , + }, + ]} + /> + + Backups are stored in the storage account associated with your CIPP instance. You can + download or restore specific points in time from the list below. Enable automatic + backups to have CIPP create daily backups using the scheduler. + + } > - - - Backups are stored in the storage account associated with your CIPP instance. You can - download or restore specific points in time from the list below. Enable automatic - backups to have CIPP create daily backups using the scheduler. - + {backupList.isSuccess ? ( - - - - - + { variant="contained" color="primary" startIcon={} - onClick={handleCreateBackup} + onClick={runBackupDialog.handleOpen} > Run Backup @@ -317,16 +289,25 @@ const Page = () => { {scheduledBackup.isSuccess && scheduledBackup.data?.[0]?.Name !== "Automated CIPP Backup" && ( - <> - - + + )} + {scheduledBackup.isSuccess && + scheduledBackup.data?.[0]?.Name === "Automated CIPP Backup" && ( + )} @@ -344,6 +325,42 @@ const Page = () => { + + + + + + { From 73a98b409393f55d2571ee0ad0618f6c4f85eda9 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 7 Mar 2026 22:38:54 -0500 Subject: [PATCH 54/64] chore: swap alert and infobar placement --- src/pages/cipp/settings/backup.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index 44434331e215..57d4a97132b2 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -210,7 +210,12 @@ const Page = () => { title="CIPP Backup" backButtonTitle="Settings" infoBar={ - + + + Backups are stored in the storage account associated with your CIPP instance. You can + download or restore specific points in time from the list below. Enable automatic + backups to have CIPP create daily backups using the scheduler. + { }, ]} /> - - Backups are stored in the storage account associated with your CIPP instance. You can - download or restore specific points in time from the list below. Enable automatic - backups to have CIPP create daily backups using the scheduler. - } > From ae81d6fc7d7cdb3dc8627edee97c6521e039c416 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:56:10 +0800 Subject: [PATCH 55/64] Feat: Incident Report and Attachment options --- .../CippTransportRuleDrawer.jsx | 153 +++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/src/components/CippComponents/CippTransportRuleDrawer.jsx b/src/components/CippComponents/CippTransportRuleDrawer.jsx index 54d69cd258f5..37e4bcf308fb 100644 --- a/src/components/CippComponents/CippTransportRuleDrawer.jsx +++ b/src/components/CippComponents/CippTransportRuleDrawer.jsx @@ -34,6 +34,13 @@ export const CippTransportRuleDrawer = ({ waiting: !!drawerVisible || !!isEditMode || !!ruleId, }); + // Fetch all rules for priority suggestion in create mode (shares cache key with list page) + const allRulesInfo = ApiGetCall({ + url: `/api/ListTransportRules?tenantFilter=${currentTenant}`, + queryKey: `List Transport Rules For Priority - ${currentTenant}`, + waiting: !!drawerVisible, + }); + // Default form values const defaultFormValues = useMemo( () => ({ @@ -116,7 +123,13 @@ export const CippTransportRuleDrawer = ({ FromAddressMatchesPatterns: "Sender address matches patterns...", AttachmentContainsWords: "Attachment content contains words...", AttachmentMatchesPatterns: "Attachment content matches patterns...", + AttachmentNameMatchesPatterns: "Attachment name matches patterns...", + AttachmentPropertyContainsWords: "Attachment properties contain words...", AttachmentExtensionMatchesWords: "Attachment extension is...", + AttachmentHasExecutableContent: "Attachment has executable content", + AttachmentIsPasswordProtected: "Attachment is password protected", + AttachmentIsUnsupported: "Attachment type is unsupported", + AttachmentProcessingLimitExceeded: "Attachment processing limit exceeded", AttachmentSizeOver: "Attachment size is greater than...", MessageSizeOver: "Message size is greater than...", SCLOver: "SCL is greater than or equal to...", @@ -290,6 +303,15 @@ export const CippTransportRuleDrawer = ({ if (rule.ApplyHtmlDisclaimerFallbackAction) { formData.ApplyHtmlDisclaimerFallbackAction = { value: rule.ApplyHtmlDisclaimerFallbackAction, label: rule.ApplyHtmlDisclaimerFallbackAction }; } + if (rule.IncidentReportContent) { + const incidentReportContentValues = Array.isArray(rule.IncidentReportContent) + ? rule.IncidentReportContent + : rule.IncidentReportContent + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + formData.IncidentReportContent = incidentReportContentValues.map((item) => ({ value: item, label: item })); + } Object.keys(actionFieldMap).forEach(field => { if (rule[field] !== null && rule[field] !== undefined && !formData[field]) { @@ -371,6 +393,28 @@ export const CippTransportRuleDrawer = ({ } }, [resetForm, drawerVisible, isEditMode]); + useEffect(() => { + if (!drawerVisible || isEditMode || !Array.isArray(allRulesInfo.data?.Results)) { + return; + } + + const priorities = allRulesInfo.data.Results + .map((rule) => Number(rule?.Priority)) + .filter((priority) => Number.isFinite(priority)); + + if (!priorities.length) { + return; + } + + const currentPriority = formControl.getValues("Priority"); + if (currentPriority === "" || currentPriority === null || currentPriority === undefined) { + formControl.setValue("Priority", Math.max(...priorities) + 1, { + shouldDirty: false, + shouldTouch: false, + }); + } + }, [drawerVisible, isEditMode, allRulesInfo.data, formControl]); + // Custom data formatter for API submission const customDataFormatter = useCallback( (values) => { @@ -456,6 +500,26 @@ export const CippTransportRuleDrawer = ({ const fallback = values.ApplyHtmlDisclaimerFallbackAction; apiData.ApplyHtmlDisclaimerFallbackAction = fallback?.value || fallback; } + } else if (actionValue === "GenerateIncidentReport") { + if (values.GenerateIncidentReport !== undefined) { + const fieldValue = values.GenerateIncidentReport; + apiData.GenerateIncidentReport = + fieldValue && typeof fieldValue === "object" && fieldValue.value !== undefined + ? fieldValue.value + : fieldValue; + } + if (values.IncidentReportContent !== undefined) { + const fieldValue = values.IncidentReportContent; + const incidentReportValues = Array.isArray(fieldValue) + ? fieldValue.map((item) => { + if (item && typeof item === "object" && item.value !== undefined) { + return item.value; + } + return item; + }) + : [fieldValue]; + apiData.IncidentReportContent = incidentReportValues.filter(Boolean).join(","); + } } else if (values[actionValue] !== undefined) { const fieldValue = values[actionValue]; @@ -642,7 +706,13 @@ export const CippTransportRuleDrawer = ({ { value: "FromAddressMatchesPatterns", label: "Sender address matches patterns..." }, { value: "AttachmentContainsWords", label: "Attachment content contains words..." }, { value: "AttachmentMatchesPatterns", label: "Attachment content matches patterns..." }, + { value: "AttachmentNameMatchesPatterns", label: "Attachment name matches patterns..." }, + { value: "AttachmentPropertyContainsWords", label: "Attachment properties contain words..." }, { value: "AttachmentExtensionMatchesWords", label: "Attachment extension is..." }, + { value: "AttachmentHasExecutableContent", label: "Attachment has executable content" }, + { value: "AttachmentIsPasswordProtected", label: "Attachment is password protected" }, + { value: "AttachmentIsUnsupported", label: "Attachment type is unsupported" }, + { value: "AttachmentProcessingLimitExceeded", label: "Attachment processing limit exceeded" }, { value: "AttachmentSizeOver", label: "Attachment size is greater than..." }, { value: "MessageSizeOver", label: "Message size is greater than..." }, { value: "SCLOver", label: "SCL is greater than or equal to..." }, @@ -686,6 +756,18 @@ export const CippTransportRuleDrawer = ({ { value: "GenerateNotification", label: "Notify the sender with a message..." }, { value: "ApplyOME", label: "Apply Office 365 Message Encryption" }, ]; + const incidentReportContentOptions = [ + { value: "Sender", label: "Sender" }, + { value: "Recipients", label: "Recipients" }, + { value: "Subject", label: "Subject" }, + { value: "CC", label: "CC" }, + { value: "BCC", label: "BCC" }, + { value: "Severity", label: "Severity" }, + { value: "RuleDetections", label: "RuleDetections" }, + { value: "FalsePositive", label: "FalsePositive" }, + { value: "IdMatch", label: "IdMatch" }, + { value: "AttachOriginalMail", label: "AttachOriginalMail" }, + ]; const renderConditionField = (condition) => { const conditionValue = condition.value || condition; @@ -850,6 +932,35 @@ export const CippTransportRuleDrawer = ({
    ); + case "AttachmentHasExecutableContent": + case "AttachmentIsPasswordProtected": + case "AttachmentIsUnsupported": + case "AttachmentProcessingLimitExceeded": + return ( + + + + ); + + case "AttachmentNameMatchesPatterns": + case "AttachmentPropertyContainsWords": + return ( + + + + ); + case "SenderDomainIs": case "RecipientDomainIs": return ( @@ -945,7 +1056,6 @@ export const CippTransportRuleDrawer = ({ case "BlindCopyTo": case "CopyTo": case "ModerateMessageByUser": - case "GenerateIncidentReport": return ( ); + case "GenerateIncidentReport": + return ( + + + + `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + dataKey: "Results", + }} + /> + + + + + + + ); + case "RouteMessageOutboundConnector": return ( From 3b67dab576b729ae6ef515ed9b021468c709bc98 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 7 Mar 2026 22:56:40 -0500 Subject: [PATCH 56/64] feat: enhance CippRestoreWizard layout with improved dialog content and stepper visibility --- .../CippComponents/CippRestoreWizard.jsx | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/components/CippComponents/CippRestoreWizard.jsx b/src/components/CippComponents/CippRestoreWizard.jsx index 373ef4a2622a..e2218d4d7e88 100644 --- a/src/components/CippComponents/CippRestoreWizard.jsx +++ b/src/components/CippComponents/CippRestoreWizard.jsx @@ -480,11 +480,25 @@ export const CippRestoreWizard = ({ return ( - + Restore Backup - + {!isLoading && ( + <> + + + + {WIZARD_STEPS.map((label) => ( + + {label} + + ))} + + + + )} + {isLoading ? ( ) : ( - - - {WIZARD_STEPS.map((label) => ( - - {label} - - ))} - - - - + )} - + From 6a12f08e933aac6a32ca6bf62cd004d68727194c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 7 Mar 2026 23:02:33 -0500 Subject: [PATCH 57/64] chore: unify table button style --- src/pages/cipp/settings/backup.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index 57d4a97132b2..dc1786a10b5e 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -265,19 +265,13 @@ const Page = () => { <> -