From b22bf7a20c3b96969c7752207f4b1e80d43b489c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 31 Jul 2025 22:58:09 -0400 Subject: [PATCH 01/34] add helper text --- src/components/CippComponents/CippFormComponent.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 7c6f7ccb6050..1e527273608f 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -29,6 +29,7 @@ import StarterKit from "@tiptap/starter-kit"; import { CippDataTable } from "../CippTable/CippDataTable"; import React from "react"; import { CloudUpload } from "@mui/icons-material"; +import { Stack } from "@mui/system"; // Helper function to convert bracket notation to dot notation // Improved to correctly handle nested bracket notations @@ -243,7 +244,16 @@ export const CippFormComponent = (props) => { return ( <> - {label} + + + {label} + {helperText && ( + + {helperText} + + )} + + Date: Thu, 31 Jul 2025 22:58:32 -0400 Subject: [PATCH 02/34] add more form conditions --- .../CippComponents/CippFormCondition.jsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/CippComponents/CippFormCondition.jsx b/src/components/CippComponents/CippFormCondition.jsx index b3630acd7098..cd310be40d99 100644 --- a/src/components/CippComponents/CippFormCondition.jsx +++ b/src/components/CippComponents/CippFormCondition.jsx @@ -181,6 +181,24 @@ export const CippFormCondition = (props) => { (item) => typeof item?.value === "string" && item.value.includes(compareValue) ) ); + case "isOneOf": + // Check if the watched value is one of the values in the compareValue array + if (!Array.isArray(compareValue)) { + console.warn( + "CippFormCondition: isOneOf compareType requires compareValue to be an array" + ); + return false; + } + return compareValue.some((value) => isEqual(watchedValue, value)); + case "isNotOneOf": + // Check if the watched value is NOT one of the values in the compareValue array + if (!Array.isArray(compareValue)) { + console.warn( + "CippFormCondition: isNotOneOf compareType requires compareValue to be an array" + ); + return false; + } + return !compareValue.some((value) => isEqual(watchedValue, value)); default: return false; } From 5cd08c1d9eb2d29555688be10b1c33106c40d635 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 4 Aug 2025 12:33:53 +0200 Subject: [PATCH 03/34] Remove domains section --- src/pages/tenant/tools/tenantlookup/index.js | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/pages/tenant/tools/tenantlookup/index.js b/src/pages/tenant/tools/tenantlookup/index.js index 2ad3fe412863..c71a22f697db 100644 --- a/src/pages/tenant/tools/tenantlookup/index.js +++ b/src/pages/tenant/tools/tenantlookup/index.js @@ -70,7 +70,7 @@ const Page = () => { - + Tenant Name: {domain} @@ -88,20 +88,6 @@ const Page = () => { : "N/A"} - - - domains: - - - {getTenant.data?.Domains?.map((domain, index) => ( -
  • - - {domain} - -
  • - ))} -
    -
    From 8bcbc1bd256f830d397e4527f10124ee4fbe9dc3 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 4 Aug 2025 13:40:16 +0200 Subject: [PATCH 04/34] Prevent portals being used before loading tenants is done --- src/pages/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/index.js b/src/pages/index.js index ef5e04907619..75dab8251e66 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -203,7 +203,7 @@ const Page = () => { Date: Mon, 4 Aug 2025 14:12:14 +0200 Subject: [PATCH 05/34] Don't load executive report data unless we click the button --- src/components/ExecutiveReportButton.js | 159 +++++++++++++++--------- src/hooks/use-securescore.js | 4 +- 2 files changed, 102 insertions(+), 61 deletions(-) diff --git a/src/components/ExecutiveReportButton.js b/src/components/ExecutiveReportButton.js index c0fa5088694a..a92e1d950174 100644 --- a/src/components/ExecutiveReportButton.js +++ b/src/components/ExecutiveReportButton.js @@ -1943,68 +1943,93 @@ export const ExecutiveReportButton = (props) => { infographics: true, }); - // Get real secure score data - const secureScore = useSecureScore(); + // Only fetch additional data when preview dialog is opened + const secureScore = useSecureScore({ waiting: previewOpen }); - // Get real license data + // Get real license data - only when preview is open const licenseData = ApiGetCall({ url: "/api/ListLicenses", data: { tenantFilter: settings.currentTenant, }, queryKey: `licenses-report-${settings.currentTenant}`, + waiting: previewOpen, }); - // Get real device data + + // Get real device data - only when preview is open const deviceData = ApiGetCall({ url: "/api/ListDevices", data: { tenantFilter: settings.currentTenant, }, queryKey: `devices-report-${settings.currentTenant}`, + waiting: previewOpen, }); - // Get real conditional access policy data + // Get real conditional access policy data - only when preview is open const conditionalAccessData = ApiGetCall({ url: "/api/ListConditionalAccessPolicies", data: { tenantFilter: settings.currentTenant, }, queryKey: `ca-policies-report-${settings.currentTenant}`, + waiting: previewOpen, }); - // Get real standards data + // Get real standards data - only when preview is open const standardsCompareData = ApiGetCall({ url: "/api/ListStandardsCompare", data: { tenantFilter: settings.currentTenant, }, queryKey: `standards-compare-report-${settings.currentTenant}`, + waiting: previewOpen, }); - // Check if all data is loaded (either successful or failed) - const isDataLoading = + // Check if all data is loaded (either successful or failed) - only relevant when preview is open + const isDataLoading = previewOpen && ( secureScore.isFetching || licenseData.isFetching || deviceData.isFetching || conditionalAccessData.isFetching || - standardsCompareData.isFetching; + standardsCompareData.isFetching + ); - const hasAllDataFinished = + const hasAllDataFinished = !previewOpen || ( (secureScore.isSuccess || secureScore.isError) && (licenseData.isSuccess || licenseData.isError) && (deviceData.isSuccess || deviceData.isError) && (conditionalAccessData.isSuccess || conditionalAccessData.isError) && - (standardsCompareData.isSuccess || standardsCompareData.isError); + (standardsCompareData.isSuccess || standardsCompareData.isError) + ); - // Show button when all data is finished loading (regardless of success/failure) - const shouldShowButton = hasAllDataFinished && !isDataLoading; + // Button is always available now since we don't need to wait for data + const shouldShowButton = true; const fileName = `Executive_Report_${tenantName?.replace(/[^a-zA-Z0-9]/g, "_") || "Tenant"}_${ new Date().toISOString().split("T")[0] }.pdf`; - // Memoize the document to prevent unnecessary re-renders + // Memoize the document to prevent unnecessary re-renders - only when dialog is open const reportDocument = useMemo(() => { + // Don't create document if dialog is closed + if (!previewOpen) { + return null; + } + + // Only create document if preview is open and data is ready + if (!hasAllDataFinished) { + return ( + + + + Loading report data... + + + + ); + } + console.log("Creating report document with:", { tenantName, tenantId, @@ -2052,18 +2077,20 @@ export const ExecutiveReportButton = (props) => { ); } }, [ + previewOpen, // Most important - prevents creation when dialog is closed + hasAllDataFinished, tenantName, tenantId, userStats, standardsData, organizationData, brandingSettings, - secureScore, - licenseData, - deviceData, - conditionalAccessData, - standardsCompareData, - sectionConfig, + secureScore?.isSuccess, + licenseData?.isSuccess, + deviceData?.isSuccess, + conditionalAccessData?.isSuccess, + standardsCompareData?.isSuccess, + JSON.stringify(sectionConfig), // Stringify to prevent reference issues ]); // Handle section toggle @@ -2084,6 +2111,11 @@ export const ExecutiveReportButton = (props) => { }); }; + // Close handler with cleanup + const handleClose = () => { + setPreviewOpen(false); + }; + // Section configuration options const sectionOptions = [ { @@ -2123,32 +2155,9 @@ export const ExecutiveReportButton = (props) => { }, ]; - // Don't render the button if data is not ready - if (!shouldShowButton) { - return ( - - - - ); - } - return ( <> - {/* Main Executive Summary Button */} + {/* Main Executive Summary Button - Always available */} - diff --git a/src/hooks/use-securescore.js b/src/hooks/use-securescore.js index 37fc9224aec3..f96c2bd232b7 100644 --- a/src/hooks/use-securescore.js +++ b/src/hooks/use-securescore.js @@ -3,7 +3,7 @@ import { ApiGetCall } from "../api/ApiCall"; import { useSettings } from "./use-settings"; import standards from "/src/data/standards.json"; -export function useSecureScore() { +export function useSecureScore({ waiting = true } = {}) { const currentTenant = useSettings().currentTenant; if (currentTenant === "AllTenants") { return { @@ -27,6 +27,7 @@ export function useSecureScore() { $top: 999, }, queryKey: `controlScore-${currentTenant}`, + waiting: waiting, }); const secureScore = ApiGetCall({ @@ -39,6 +40,7 @@ export function useSecureScore() { $top: 7, }, queryKey: `secureScore-${currentTenant}`, + waiting: waiting, }); useEffect(() => { From 417f17924c13b8fef30d240ff274f2d5627f0c80 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 4 Aug 2025 11:41:50 -0400 Subject: [PATCH 06/34] fix link --- .../tenant/standards/list-standards/classic-standards/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tenant/standards/list-standards/classic-standards/index.js b/src/pages/tenant/standards/list-standards/classic-standards/index.js index 958502d70541..b650b4325433 100644 --- a/src/pages/tenant/standards/list-standards/classic-standards/index.js +++ b/src/pages/tenant/standards/list-standards/classic-standards/index.js @@ -22,7 +22,7 @@ const Page = () => { const actions = [ { label: "View Tenant Report", - link: "/tenant/standards/compare?templateId=[GUID]", + link: "/tenant/standards/manage-drift/compare?templateId=[GUID]", icon: , color: "info", target: "_self", From da98f976de106da0f1cbd20c2c771109b991e585 Mon Sep 17 00:00:00 2001 From: Zac Richards <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:49:41 +0800 Subject: [PATCH 07/34] Add start date-time picker to alert configuration Introduces a date-time picker for specifying the desired start time of an alert. The start time is parsed from the alert data if available and included in form state and submission payload. --- .../alert-configuration/alert.jsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index fc3950a47a4e..44e85901284f 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -132,6 +132,13 @@ const AlertWizard = () => { }; } + // Parse the original desired start date-time from DesiredStartTime field if it exists + let startDateTimeForForm = null; + if (alert.RawAlert.DesiredStartTime && alert.RawAlert.DesiredStartTime !== "0") { + const desiredStartEpoch = parseInt(alert.RawAlert.DesiredStartTime); + startDateTimeForForm = desiredStartEpoch; + } + // Create the reset object with all the form values const resetObject = { tenantFilter: tenantFilterForForm, @@ -139,6 +146,7 @@ const AlertWizard = () => { command: { value: usedCommand, label: usedCommand.label }, recurrence: recurrenceOption, postExecution: postExecutionValue, + startDateTime: startDateTimeForForm, }; // Parse Parameters field if it exists and is a string @@ -332,6 +340,7 @@ const AlertWizard = () => { Command: { value: `Get-CIPPAlert${values.command.value.name}` }, Parameters: getInputParams(), ScheduledTime: Math.floor(new Date().getTime() / 1000) + 60, + DesiredStartTime: values.startDateTime ? values.startDateTime.toString() : null, Recurrence: values.recurrence, PostExecution: values.postExecution, }; @@ -700,6 +709,15 @@ const AlertWizard = () => { options={recurrenceOptions} // Use the state-managed recurrenceOptions here /> + + + {commandValue?.value?.requiresInput && ( Date: Tue, 5 Aug 2025 19:09:57 -0400 Subject: [PATCH 08/34] new backup validation --- src/pages/cipp/settings/backup.js | 304 ++++++++++- src/utils/backupValidation.js | 796 +++++++++++++++++++++++++++++ src/utils/backupValidationTests.js | 144 ++++++ 3 files changed, 1230 insertions(+), 14 deletions(-) create mode 100644 src/utils/backupValidation.js create mode 100644 src/utils/backupValidationTests.js diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index 1931dca8f87a..ff04464b9e77 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -1,4 +1,16 @@ -import { Box, Button, CardContent, Stack, Typography, Skeleton } from "@mui/material"; +import { + Box, + Button, + CardContent, + Stack, + Typography, + Skeleton, + Alert, + AlertTitle, + Input, + FormControl, + FormLabel, +} from "@mui/material"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; import CippPageCard from "../../../components/CippCards/CippPageCard"; import { ApiGetCall, ApiPostCall } from "../../../api/ApiCall"; @@ -13,12 +25,24 @@ 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 { useState } from "react"; const Page = () => { + const [validationResult, setValidationResult] = useState(null); + const [restoreDialog, setRestoreDialog] = useState({ open: false }); + const [selectedBackupFile, setSelectedBackupFile] = useState(null); + const [selectedBackupData, setSelectedBackupData] = useState(null); + const backupList = ApiGetCall({ url: "/api/ExecListBackup", data: { @@ -53,6 +77,64 @@ 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.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. + + + 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)) { @@ -80,20 +162,52 @@ const Page = () => { const handleRestoreBackupUpload = (e) => { const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); reader.onload = (e) => { - const backup = JSON.parse(e.target.result); - backupAction.mutate( - { - url: "/api/ExecRestoreBackup", - data: backup, - }, - { - onSuccess: () => { - e.target.value = null; - }, + try { + const rawContent = e.target.result; + + // Validate the backup file + 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), + }); + + if (validation.isValid) { + setSelectedBackupData(validation.data); + } else { + setSelectedBackupData(null); } - ); + + // Open the confirmation dialog + setRestoreDialog({ open: true }); + + // 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), + }); + setSelectedBackupData(null); + setRestoreDialog({ open: true }); + e.target.value = null; + } }; reader.readAsText(file); }; @@ -110,11 +224,41 @@ const Page = () => { if (!jsonString) { return; } - const blob = new Blob([jsonString], { type: "application/json" }); + + // Validate the backup before downloading + const validation = BackupValidator.validateBackup(jsonString); + + let finalJsonString = 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}`; + } + + const blob = new Blob([downloadContent], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `${row.BackupName}.json`; + a.download = `${row.BackupName}${validation.repaired ? "_repaired" : ""}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -195,6 +339,7 @@ const Page = () => { + { )} + + {/* Backup Restore Confirmation Dialog */} + { + setRestoreDialog({ open: false }); + 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: () => { + setRestoreDialog({ open: false }); + setValidationResult(null); + setSelectedBackupFile(null); + setSelectedBackupData(null); + }, + }} + 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 + + )} + + + + )} + + )} + ); }; diff --git a/src/utils/backupValidation.js b/src/utils/backupValidation.js new file mode 100644 index 000000000000..c5c82e763d64 --- /dev/null +++ b/src/utils/backupValidation.js @@ -0,0 +1,796 @@ +/** + * CIPP Backup Validation Utility + * Validates and attempts to repair corrupted backup JSON files + */ + +export class BackupValidationError extends Error { + constructor(message, details = {}) { + super(message); + this.name = "BackupValidationError"; + this.details = details; + } +} + +export const BackupValidator = { + /** + * Validates a backup file before attempting to parse + * @param {string} jsonString - Raw JSON string from file + * @returns {Object} - Validation result with status and data/errors + */ + validateBackup(jsonString) { + const result = { + isValid: false, + data: null, + errors: [], + warnings: [], + repaired: false, + }; + + try { + // Step 1: Basic checks + const basicValidation = this._performBasicValidation(jsonString); + if (!basicValidation.isValid) { + // Store initial errors but don't immediately add them to result + const initialErrors = [...basicValidation.errors]; + + // Attempt repair if issues are detected + const repairResult = this._attemptRepair(jsonString); + if (repairResult.success) { + result.warnings.push("Backup file had issues but was successfully repaired"); + result.repaired = true; + jsonString = repairResult.repairedJson; + // Clear errors since repair was successful + // Don't add initialErrors to result.errors + } else { + // Add the initial errors since repair failed + result.errors.push(...initialErrors); + + // If basic repair failed, try advanced repair immediately + result.warnings.push("Basic repair failed, attempting advanced recovery..."); + const advancedResult = this._attemptAdvancedRepair(jsonString); + + // Test if advanced repair produced valid JSON + try { + const advancedData = JSON.parse(advancedResult); + if (Array.isArray(advancedData) && advancedData.length > 0) { + result.warnings.push("Advanced recovery successful"); + result.repaired = true; + jsonString = advancedResult; + // Clear the basic repair errors since advanced repair worked + result.errors = []; + } else { + result.errors.push(...repairResult.errors); + return result; + } + } catch { + result.errors.push(...repairResult.errors); + return result; + } + } + } + + // Step 2: Parse JSON + let parsedData; + try { + parsedData = JSON.parse(jsonString); + } catch (parseError) { + result.errors.push(`JSON parsing failed: ${parseError.message}`); + return result; + } + + // Step 3: Validate structure and filter out corrupted objects if needed + const structureValidation = this._validateBackupStructure(parsedData); + if (!structureValidation.isValid) { + result.errors.push(...structureValidation.errors); + } + result.warnings.push(...structureValidation.warnings); + + // If we had to omit corrupted objects, filter the data to only include valid ones + if ( + Array.isArray(parsedData) && + structureValidation.warnings.some((w) => w.includes("omitted")) + ) { + const filteredData = parsedData.filter((item) => this._isValidCippObject(item)); + if (filteredData.length > 0) { + result.data = filteredData; + result.repaired = true; + result.warnings.push( + `Filtered backup data: ${filteredData.length} valid objects retained` + ); + } else { + result.errors.push("No valid objects remaining after filtering corrupted data"); + return result; + } + } else { + result.data = parsedData; + } + + // Step 4: Validate data integrity + const integrityValidation = this._validateDataIntegrity(result.data || parsedData); + if (!integrityValidation.isValid) { + result.warnings.push(...integrityValidation.warnings); + } + + result.isValid = result.errors.length === 0; + if (!result.data) { + result.data = parsedData; + } + } catch (error) { + result.errors.push(`Validation failed: ${error.message}`); + } + + return result; + }, + + /** + * Performs basic validation checks on the raw JSON string + */ + _performBasicValidation(jsonString) { + const result = { isValid: true, errors: [] }; + + // Check if string is empty or null + if (!jsonString || jsonString.trim().length === 0) { + result.isValid = false; + result.errors.push("Backup file is empty"); + return result; + } + + // Check for basic JSON structure + const trimmed = jsonString.trim(); + if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) { + result.isValid = false; + result.errors.push("Invalid JSON format: must start with [ or {"); + } + + if (!trimmed.endsWith("]") && !trimmed.endsWith("}")) { + result.isValid = false; + result.errors.push("Invalid JSON format: must end with ] or }"); + } + + // Check for common corruption patterns + const corruptionPatterns = [ + { pattern: /\\\",\"val\w*\":\s*$/, description: "Truncated escape sequences detected" }, + { pattern: /"[^"]*\n[^"]*"/, description: "Unescaped newlines in strings detected" }, + { pattern: /\{[^}]*$/, description: "Unclosed object brackets detected" }, + { pattern: /\[[^\]]*$/, description: "Unclosed array brackets detected" }, + { pattern: /\"[^"]*\\$/, description: "Incomplete escape sequences detected" }, + { pattern: /,\s*[}\]]/, description: "Trailing commas detected" }, + ]; + + for (const { pattern, description } of corruptionPatterns) { + if (pattern.test(jsonString)) { + result.isValid = false; + result.errors.push(description); + } + } + + return result; + }, + + /** + * Attempts to repair common JSON corruption issues + */ + _attemptRepair(jsonString) { + const result = { success: false, repairedJson: jsonString, errors: [] }; + + try { + let repaired = jsonString; + + // Fix 1: Remove trailing commas + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + + // Fix 2: Fix common escape sequence issues + repaired = repaired.replace(/\\",\\"val/g, '\\",\\"value'); + + // Fix 2.5: Fix unescaped newlines within JSON strings + // Escape literal newline characters within quoted strings to make them valid JSON + repaired = repaired.replace(/"([^"]*)\n([^"]*)"/g, '"$1\\n$2"'); + + // Fix 3: Try to close unclosed brackets (basic attempt) + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + + // Add missing closing braces/brackets if the difference is reasonable (< 5) + if (openBraces - closeBraces > 0 && openBraces - closeBraces < 5) { + repaired += "}}".repeat(openBraces - closeBraces); + } + if (openBrackets - closeBrackets > 0 && openBrackets - closeBrackets < 5) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + + // Fix 4: Handle corrupted JWT/Base64 tokens that get mixed with other data + // Pattern: Base64 string followed by unexpected characters then "label" + repaired = repaired.replace(/([A-Za-z0-9+/=]{50,})"([^"]*)"label/g, '$1"}}, {\\"label'); + + // Fix 5: Handle broken string continuation patterns + repaired = repaired.replace(/"([^"]*)"([A-Za-z]+)":/g, '"$1"},"$2":'); + + // Fix 6: Handle truncated strings that jump to new fields + // Pattern: incomplete string followed by field name without proper closure + repaired = repaired.replace(/([^\\])"([a-zA-Z_][a-zA-Z0-9_]*)":/g, '$1","$2":'); + + // Fix 7: Remove invalid trailing characters after last valid JSON structure + const lastBraceIndex = repaired.lastIndexOf("}"); + const lastBracketIndex = repaired.lastIndexOf("]"); + const lastValidIndex = Math.max(lastBraceIndex, lastBracketIndex); + + if (lastValidIndex > 0 && lastValidIndex < repaired.length - 1) { + const afterLastValid = repaired.substring(lastValidIndex + 1).trim(); + // If there's significant content after the last valid structure, it might be corruption + if (afterLastValid.length > 10 && !afterLastValid.match(/^[\s,]*$/)) { + repaired = repaired.substring(0, lastValidIndex + 1); + } + } + + // Fix 8: Advanced corruption repair - try to find and isolate corrupted entries + if (!this._isValidJson(repaired)) { + repaired = this._attemptAdvancedRepair(repaired); + } + + // Try to parse the repaired JSON + try { + JSON.parse(repaired); + result.success = true; + result.repairedJson = repaired; + } catch (parseError) { + result.errors.push(`Repair attempt failed: ${parseError.message}`); + } + } catch (error) { + result.errors.push(`Repair process failed: ${error.message}`); + } + + return result; + }, + + /** + * Helper function to check if a string is valid JSON + */ + _isValidJson(str) { + try { + JSON.parse(str); + return true; + } catch { + return false; + } + }, + + /** + * Advanced repair for severely corrupted JSON + */ + _attemptAdvancedRepair(jsonString) { + try { + // Strategy 1: Try to extract individual valid JSON objects from the corrupted string + // using a robust pattern matching approach that finds complete objects + + const validObjects = []; + let corruptedCount = 0; + + // Method 1: Look for PartitionKey patterns and extract complete objects + const partitionKeyPattern = /"PartitionKey"\s*:\s*"[^"]*"/g; + let partitionMatch; + + while ((partitionMatch = partitionKeyPattern.exec(jsonString)) !== null) { + try { + // Find the object that contains this PartitionKey + const startPos = jsonString.lastIndexOf("{", partitionMatch.index); + if (startPos === -1) continue; + + // Count braces to find the complete object + let braceCount = 0; + let endPos = startPos; + + for (let i = startPos; i < jsonString.length; i++) { + if (jsonString[i] === "{") braceCount++; + if (jsonString[i] === "}") braceCount--; + + if (braceCount === 0) { + endPos = i; + break; + } + } + + if (braceCount === 0) { + const candidateObject = jsonString.substring(startPos, endPos + 1); + + // Clean up common issues more aggressively + let cleanObject = candidateObject + .replace(/,(\s*[}\]])/g, "$1") // Remove trailing commas + .replace(/([^\\])\\n/g, "$1\\\\n") // Fix unescaped newlines + .replace(/\n/g, " ") // Replace actual newlines with spaces + .replace(/([^"\\])\\"([^"]*)\\"([^"\\])/g, '$1"$2"$3') // Fix over-escaped quotes + .replace(/\\\\/g, "\\"); // Fix double escaping + + try { + const obj = JSON.parse(cleanObject); + if (obj.PartitionKey && obj.RowKey && this._isValidCippObject(obj)) { + // Check if we already added this object (avoid duplicates) + const duplicate = validObjects.find( + (existing) => + existing.PartitionKey === obj.PartitionKey && existing.RowKey === obj.RowKey + ); + if (!duplicate) { + validObjects.push(obj); + } + } else { + corruptedCount++; + } + } catch (firstError) { + // Try more aggressive repair before giving up + try { + let aggressiveRepair = candidateObject + .replace(/,(\s*[}\]])/g, "$1") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") + .replace(/([^\\])"/g, '$1\\"') // More aggressive quote escaping + .replace(/\\\\"$/, '"'); // Fix ending quotes + + const obj2 = JSON.parse(aggressiveRepair); + if (obj2.PartitionKey && obj2.RowKey) { + const duplicate = validObjects.find( + (existing) => + existing.PartitionKey === obj2.PartitionKey && existing.RowKey === obj2.RowKey + ); + if (!duplicate) { + validObjects.push(obj2); + } + } else { + corruptedCount++; + } + } catch (secondError) { + corruptedCount++; + } + } + } + } catch { + corruptedCount++; + } + } + + if (validObjects.length > 0) { + console.log( + `Recovered ${validObjects.length} valid objects, omitted ${corruptedCount} corrupted objects` + ); + return JSON.stringify(validObjects); + } + + // Strategy 2: Try line-by-line parsing for objects that span multiple lines + const lines = jsonString.split("\n"); + let currentObject = ""; + let braceCount = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + if (line.startsWith('{"PartitionKey"')) { + currentObject = line; + braceCount = (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length; + } else if (currentObject && braceCount > 0) { + currentObject += line; + braceCount += (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length; + } + + // If we have a complete object (braces balanced) + if (currentObject && braceCount === 0) { + try { + // Remove trailing comma if present + const cleanObject = currentObject.replace(/,$/, ""); + const obj = JSON.parse(cleanObject); + + if (obj.PartitionKey && obj.RowKey && this._isValidCippObject(obj)) { + validObjects.push(obj); + } else { + corruptedCount++; + } + } catch { + corruptedCount++; + } + + currentObject = ""; + } + } + + if (validObjects.length > 0) { + console.log( + `Line-by-line recovery: ${validObjects.length} valid objects, omitted ${corruptedCount} corrupted objects` + ); + return JSON.stringify(validObjects); + } + + // Strategy 3: More aggressive pattern matching for partial objects + console.log("Attempting aggressive pattern-based recovery..."); + + // First, try to fix common newline issues in the original string + let cleanedJson = jsonString; + + // Fix unescaped newlines within property values (but preserve structural JSON) + cleanedJson = cleanedJson.replace(/"([^"]*)\n([^"]*)"/g, '"$1\\n$2"'); + + // Try parsing the cleaned version first + try { + const cleanedParsed = JSON.parse(cleanedJson); + if (Array.isArray(cleanedParsed)) { + const validCleanedObjects = cleanedParsed.filter((obj) => this._isValidCippObject(obj)); + if (validCleanedObjects.length > 0) { + console.log(`Newline repair: ${validCleanedObjects.length} objects recovered`); + return JSON.stringify(validCleanedObjects); + } + } + } catch (e) { + // Continue with aggressive pattern matching + } + + const aggressivePattern = + /"PartitionKey"\s*:\s*"([^"]*)"[\s\S]*?"RowKey"\s*:\s*"([^"]*)"[\s\S]*?(?="PartitionKey"|$)/g; + let aggressiveMatch; + + while ((aggressiveMatch = aggressivePattern.exec(cleanedJson)) !== null) { + try { + const partitionKey = aggressiveMatch[1]; + const rowKey = aggressiveMatch[2]; + const candidateText = aggressiveMatch[0]; + + // Try to reconstruct a minimal valid object + const minimalObject = { + PartitionKey: partitionKey, + RowKey: rowKey, + }; + + // Extract additional fields using regex + const fieldPattern = /"([^"]+)"\s*:\s*"([^"]*)"/g; + let fieldMatch; + + while ((fieldMatch = fieldPattern.exec(candidateText)) !== null) { + const fieldName = fieldMatch[1]; + const fieldValue = fieldMatch[2]; + + // Skip the fields we already have and avoid problematic ones + if ( + fieldName !== "PartitionKey" && + fieldName !== "RowKey" && + fieldValue.length < 10000 && + !fieldValue.includes('"PartitionKey"') + ) { + minimalObject[fieldName] = fieldValue; + } + } + + // Only add if we have at least 3 fields (PartitionKey, RowKey, and one more) + if (Object.keys(minimalObject).length >= 3) { + const duplicate = validObjects.find( + (existing) => + existing.PartitionKey === minimalObject.PartitionKey && + existing.RowKey === minimalObject.RowKey + ); + if (!duplicate) { + validObjects.push(minimalObject); + } + } + } catch { + // Skip this match + } + } + + if (validObjects.length > 0) { + console.log(`Aggressive recovery: ${validObjects.length} partial objects reconstructed`); + return JSON.stringify(validObjects); + } + + // Strategy 4: If that fails, try to truncate at the first major corruption point + for (let i = 0; i < lines.length; i++) { + try { + const partialJson = lines.slice(0, i + 1).join("\n"); + // Try to fix basic bracket issues and test + let testJson = partialJson; + const openBraces = (testJson.match(/\{/g) || []).length; + const closeBraces = (testJson.match(/\}/g) || []).length; + const openBrackets = (testJson.match(/\[/g) || []).length; + const closeBrackets = (testJson.match(/\]/g) || []).length; + + // Add missing closures + if (openBraces > closeBraces) { + testJson += "}".repeat(openBraces - closeBraces); + } + if (openBrackets > closeBrackets) { + testJson += "]".repeat(openBrackets - closeBrackets); + } + + const parsed = JSON.parse(testJson); + if (Array.isArray(parsed) && parsed.length > 0) { + // Filter out any invalid objects from the truncated result + const validParsed = parsed.filter((obj) => this._isValidCippObject(obj)); + if (validParsed.length > 0) { + console.log(`Truncation recovery: ${validParsed.length} valid objects recovered`); + return JSON.stringify(validParsed); + } + } + } catch { + continue; + } + } + } catch (error) { + console.warn("Advanced repair failed:", error); + } + + return jsonString; // Return original if all repairs fail + }, + + /** + * Validates if an object is a valid CIPP backup object + */ + _isValidCippObject(obj) { + try { + // Basic structure validation + if (typeof obj !== "object" || obj === null) { + return false; + } + + // Required fields for CIPP backup objects + if (!obj.PartitionKey || !obj.RowKey) { + return false; + } + + // Check for obvious corruption patterns in string values + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string") { + // Check for common corruption indicators + if ( + value.includes("\0") || // Null bytes + value.length > 500000 + ) { + // Very large strings (>500KB) + return false; + } + + // More specific JWT token corruption pattern - only flag if clearly corrupted + // Look for Base64 that's truncated mid-token and followed by unescaped content + if (value.match(/[A-Za-z0-9+/=]{100,}[^A-Za-z0-9+/=\s].*"label"[^"]*"[^"]*"[a-zA-Z]/)) { + console.log(`Detected severe JWT corruption in ${key}: ${value.substring(0, 100)}...`); + return false; + } + + // Check for obvious object data mixing (but be more lenient) + if ( + (value.includes('"PartitionKey":"') && + value.includes('"RowKey":"') && + value.includes('"table":"')) || + value.match(/"PartitionKey":"[^"]*","RowKey":"[^"]*"/) + ) { + console.log(`Detected obvious object mixing in ${key}`); + return false; + } + + // Check for severely broken JSON structure within strings + if (value.includes('"},"') && value.includes('{"') && !this._looksLikeValidJson(value)) { + // Try to determine if this is actually corrupted or just complex nested JSON + const jsonChunks = value.split('"},'); + let validChunks = 0; + for (const chunk of jsonChunks.slice(0, 3)) { + // Check first few chunks + try { + JSON.parse(chunk + "}"); + validChunks++; + } catch { + // Invalid chunk + } + } + + // If most chunks are invalid, consider it corrupted + if (validChunks === 0 && jsonChunks.length > 1) { + console.log(`Detected broken JSON structure in ${key}`); + return false; + } + } + + // If it looks like JSON, try to parse it (but be more forgiving) + if (this._looksLikeJson(value)) { + try { + JSON.parse(value); + } catch (parseError) { + // Try some basic repairs before giving up + const repairedValue = this._attemptStringRepair(value); + try { + JSON.parse(repairedValue); + // If repair succeeded, continue with the object + } catch { + // Only reject if repair also failed + return false; + } + } + } + } + } + + return true; + } catch { + return false; + } + }, + + /** + * Attempt basic repairs on corrupted JSON strings + */ + _attemptStringRepair(str) { + let repaired = str; + + // Fix common issues + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); // Remove trailing commas + repaired = repaired.replace(/([^\\])\\n/g, "$1\\\\n"); // Fix unescaped newlines + repaired = repaired.replace(/\n/g, " "); // Replace actual newlines with spaces + repaired = repaired.replace(/([^\\])"/g, '$1\\"'); // Fix unescaped quotes (be careful) + + return repaired; + }, + + /** + * More strict validation for JSON-like strings + */ + _looksLikeValidJson(str) { + if (typeof str !== "string" || str.length < 2) return false; + const trimmed = str.trim(); + + // Must start and end with proper JSON delimiters + if (trimmed.startsWith("{") && trimmed.endsWith("}")) return true; + if (trimmed.startsWith("[") && trimmed.endsWith("]")) return true; + if (trimmed.startsWith('"') && trimmed.endsWith('"')) return true; + + return false; + }, + + /** + * Validates the structure of parsed backup data + */ + _validateBackupStructure(data) { + const result = { isValid: true, errors: [], warnings: [] }; + + // Check if data is an array (expected format for CIPP backups) + if (!Array.isArray(data)) { + result.warnings.push("Backup data is not an array - may be an older format"); + } + + // If it's an array, validate each entry + if (Array.isArray(data)) { + let validCount = 0; + let invalidCount = 0; + + data.forEach((item, index) => { + if (typeof item !== "object" || item === null) { + result.warnings.push(`Item at index ${index} is not a valid object`); + invalidCount++; + return; + } + + // Check for required CIPP backup fields + const expectedFields = ["PartitionKey", "RowKey"]; + const missingFields = expectedFields.filter((field) => !(field in item)); + + if (missingFields.length > 0) { + result.warnings.push( + `Item at index ${index} missing required fields: ${missingFields.join(", ")}` + ); + invalidCount++; + return; + } + + // Additional validation using the CIPP object validator + if (!this._isValidCippObject(item)) { + result.warnings.push(`Item at index ${index} contains corrupted data and was omitted`); + invalidCount++; + return; + } + + validCount++; + + // Check for corrupted JSON strings within valid objects + Object.keys(item).forEach((key) => { + const value = item[key]; + if (typeof value === "string" && this._looksLikeJson(value)) { + try { + JSON.parse(value); + } catch { + result.warnings.push( + `Item at index ${index}, field '${key}' contains invalid JSON but object was kept` + ); + } + } + }); + }); + + // Summary information + if (invalidCount > 0) { + result.warnings.push( + `Backup recovery summary: ${validCount} valid objects restored, ${invalidCount} corrupted objects omitted` + ); + } + + // Only mark as invalid if we have no valid objects at all + if (validCount === 0 && data.length > 0) { + result.isValid = false; + result.errors.push("No valid CIPP backup objects found"); + } + } + + return result; + }, + + /** + * Validates data integrity and completeness + */ + _validateDataIntegrity(data) { + const result = { isValid: true, warnings: [] }; + + if (Array.isArray(data)) { + // Check for duplicate entries + const seen = new Set(); + const duplicates = []; + + data.forEach((item, index) => { + if (item.PartitionKey && item.RowKey) { + const key = `${item.PartitionKey}:${item.RowKey}`; + if (seen.has(key)) { + duplicates.push(`Duplicate entry at index ${index}: ${key}`); + } else { + seen.add(key); + } + } + }); + + if (duplicates.length > 0) { + result.warnings.push(...duplicates); + } + + // Check for suspiciously large entries (potential corruption) + data.forEach((item, index) => { + const itemSize = JSON.stringify(item).length; + if (itemSize > 50000) { + // 50KB threshold + result.warnings.push( + `Item at index ${index} is unusually large (${itemSize} bytes) - may be corrupted` + ); + } + }); + + // Basic statistics + const tables = data.map((item) => item.table).filter(Boolean); + const uniqueTables = [...new Set(tables)]; + + if (uniqueTables.length === 0) { + result.warnings.push("No table information found in backup"); + } + } + + return result; + }, + + /** + * Helper function to detect if a string looks like JSON + */ + _looksLikeJson(str) { + if (typeof str !== "string" || str.length < 2) return false; + const trimmed = str.trim(); + return ( + (trimmed.startsWith("{") && trimmed.includes("}")) || + (trimmed.startsWith("[") && trimmed.includes("]")) + ); + }, + + /** + * Sanitizes backup data for display/logging purposes + */ + sanitizeForDisplay(data, maxLength = 100) { + if (typeof data === "string") { + return data.length > maxLength ? data.substring(0, maxLength) + "..." : data; + } + + try { + const jsonStr = JSON.stringify(data); + return jsonStr.length > maxLength ? jsonStr.substring(0, maxLength) + "..." : jsonStr; + } catch { + return "[Unserializable data]"; + } + }, +}; + +export default BackupValidator; diff --git a/src/utils/backupValidationTests.js b/src/utils/backupValidationTests.js new file mode 100644 index 000000000000..df24fcd826cd --- /dev/null +++ b/src/utils/backupValidationTests.js @@ -0,0 +1,144 @@ +/** + * Test suite for CIPP Backup Validation + */ +import { BackupValidator } from "../utils/backupValidation.js"; + +// Test cases based on the bad-json.json patterns +const testCases = { + validBackup: { + name: "Valid Backup", + data: JSON.stringify([ + { + PartitionKey: "TestKey", + RowKey: "TestRow", + table: "TestTable", + data: "test data", + }, + ]), + expectedValid: true, + }, + + emptyFile: { + name: "Empty File", + data: "", + expectedValid: false, + }, + + truncatedEscapes: { + name: "Truncated Escape Sequences", + data: '[{"PartitionKey":"Test","value":"truncated\\",,"RowKey":"test"]', + expectedValid: false, + }, + + unclosedBrackets: { + name: "Unclosed Brackets", + data: '[{"PartitionKey":"Test","RowKey":"test","data":{"nested":"value"', + expectedValid: false, + }, + + trailingCommas: { + name: "Trailing Commas", + data: '[{"PartitionKey":"Test","RowKey":"test",}]', + expectedValid: false, + }, + + corruptedMiddle: { + name: "Corrupted in Middle", + data: '[{"PartitionKey":"Test1","RowKey":"test1"},{"PartitionKey":"Test2\\",,"RowKey":"incomplete"},{"PartitionKey":"Test3","RowKey":"test3"}]', + expectedValid: false, + }, + + malformedJson: { + name: "Malformed JSON Structure", + data: '{"not": "an array", "but": "object"}', + expectedValid: true, // Should warn but still be valid + }, + + duplicateEntries: { + name: "Duplicate Entries", + data: JSON.stringify([ + { PartitionKey: "Test", RowKey: "duplicate", table: "TestTable" }, + { PartitionKey: "Test", RowKey: "duplicate", table: "TestTable" }, + ]), + expectedValid: true, // Should warn but still be valid + }, +}; + +/** + * Run all test cases and log results + */ +export function runBackupValidationTests() { + console.log("🧪 Running CIPP Backup Validation Tests...\n"); + + let passed = 0; + let failed = 0; + + Object.entries(testCases).forEach(([key, testCase]) => { + console.log(`Testing: ${testCase.name}`); + + try { + const result = BackupValidator.validateBackup(testCase.data); + + const testPassed = result.isValid === testCase.expectedValid; + + if (testPassed) { + console.log(`✅ PASS - Valid: ${result.isValid}, Repaired: ${result.repaired}`); + passed++; + } else { + console.log(`❌ FAIL - Expected: ${testCase.expectedValid}, Got: ${result.isValid}`); + failed++; + } + + if (result.errors.length > 0) { + console.log(` Errors: ${result.errors.join(", ")}`); + } + + if (result.warnings.length > 0) { + console.log(` Warnings: ${result.warnings.join(", ")}`); + } + } catch (error) { + console.log(`❌ FAIL - Exception: ${error.message}`); + failed++; + } + + console.log(""); + }); + + console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`); + + if (failed === 0) { + console.log("🎉 All tests passed!"); + } else { + console.log("⚠️ Some tests failed - check implementation"); + } +} + +/** + * Test with actual corrupted data from bad-json.json pattern + */ +export function testWithCorruptedSample() { + console.log("🔍 Testing with corrupted sample data...\n"); + + // Simulate the corrupted pattern from the bad-json.json file + const corruptedSample = `[{"PartitionKey":"CIPP-SAM","RowKey":"CIPP-SAM","Permissions":"{\\"00000003-0000-0000-c000-000000000000\\":{\\"delegatedPermissions\\":[{\\"id\\":\\"bdfbf15f-ee85-4955-8675-146e8e5296b5\\",\\"value\\":\\"Application.ReadWrite.All\\"}],\\"applicationPermissions\\":[{\\"id\\":\\"1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9\\",\\"val`; + + const result = BackupValidator.validateBackup(corruptedSample); + + console.log("Validation Result:"); + console.log(`- Valid: ${result.isValid}`); + console.log(`- Repaired: ${result.repaired}`); + console.log(`- Errors: ${result.errors.length > 0 ? result.errors.join(", ") : "None"}`); + console.log(`- Warnings: ${result.warnings.length > 0 ? result.warnings.join(", ") : "None"}`); + + if (result.data) { + console.log( + `- Parsed entries: ${Array.isArray(result.data) ? result.data.length : "Not array"}` + ); + } +} + +// Export for console testing +if (typeof window !== "undefined") { + window.testBackupValidation = runBackupValidationTests; + window.testCorruptedSample = testWithCorruptedSample; +} From 387ac1e2bb4119b3d2f68a79131e50f447c85923 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 5 Aug 2025 20:49:07 -0400 Subject: [PATCH 09/34] fix issues with backup repair --- src/pages/cipp/settings/backup.js | 31 +- src/utils/backupValidation.js | 865 ++++++++---------------------- 2 files changed, 247 insertions(+), 649 deletions(-) diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index ff04464b9e77..d1fad91f241b 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -36,10 +36,11 @@ import { CippApiResults } from "../../../components/CippComponents/CippApiResult import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; import { BackupValidator, BackupValidationError } from "../../../utils/backupValidation"; import { useState } from "react"; +import { useDialog } from "../../../hooks/use-dialog"; const Page = () => { const [validationResult, setValidationResult] = useState(null); - const [restoreDialog, setRestoreDialog] = useState({ open: false }); + const restoreDialog = useDialog(); const [selectedBackupFile, setSelectedBackupFile] = useState(null); const [selectedBackupData, setSelectedBackupData] = useState(null); @@ -86,7 +87,15 @@ const Page = () => { {result.isValid ? ( }> Backup Validation Successful - The backup file is valid and ready for restoration. + + 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 @@ -113,7 +122,15 @@ const Page = () => { ) : ( }> Backup Validation Failed - The backup file is corrupted and cannot be restored safely. + + 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: @@ -187,7 +204,7 @@ const Page = () => { } // Open the confirmation dialog - setRestoreDialog({ open: true }); + restoreDialog.handleOpen(); // Clear the file input e.target.value = null; @@ -205,7 +222,7 @@ const Page = () => { lastModified: new Date(file.lastModified), }); setSelectedBackupData(null); - setRestoreDialog({ open: true }); + restoreDialog.handleOpen(); e.target.value = null; } }; @@ -408,7 +425,7 @@ const Page = () => { createDialog={{ open: restoreDialog.open, handleClose: () => { - setRestoreDialog({ open: false }); + restoreDialog.handleClose(); setValidationResult(null); setSelectedBackupFile(null); setSelectedBackupData(null); @@ -422,7 +439,7 @@ const Page = () => { ? "Are you sure you want to restore this backup? This will overwrite your current CIPP configuration." : null, onSuccess: () => { - setRestoreDialog({ open: false }); + restoreDialog.handleClose(); setValidationResult(null); setSelectedBackupFile(null); setSelectedBackupData(null); diff --git a/src/utils/backupValidation.js b/src/utils/backupValidation.js index c5c82e763d64..ae87a63c5f22 100644 --- a/src/utils/backupValidation.js +++ b/src/utils/backupValidation.js @@ -24,96 +24,60 @@ export const BackupValidator = { errors: [], warnings: [], repaired: false, + validRows: 0, + totalRows: 0, }; try { // Step 1: Basic checks - const basicValidation = this._performBasicValidation(jsonString); - if (!basicValidation.isValid) { - // Store initial errors but don't immediately add them to result - const initialErrors = [...basicValidation.errors]; - - // Attempt repair if issues are detected - const repairResult = this._attemptRepair(jsonString); - if (repairResult.success) { - result.warnings.push("Backup file had issues but was successfully repaired"); - result.repaired = true; - jsonString = repairResult.repairedJson; - // Clear errors since repair was successful - // Don't add initialErrors to result.errors - } else { - // Add the initial errors since repair failed - result.errors.push(...initialErrors); - - // If basic repair failed, try advanced repair immediately - result.warnings.push("Basic repair failed, attempting advanced recovery..."); - const advancedResult = this._attemptAdvancedRepair(jsonString); - - // Test if advanced repair produced valid JSON - try { - const advancedData = JSON.parse(advancedResult); - if (Array.isArray(advancedData) && advancedData.length > 0) { - result.warnings.push("Advanced recovery successful"); - result.repaired = true; - jsonString = advancedResult; - // Clear the basic repair errors since advanced repair worked - result.errors = []; - } else { - result.errors.push(...repairResult.errors); - return result; - } - } catch { - result.errors.push(...repairResult.errors); - return result; - } - } + if (!jsonString || jsonString.trim().length === 0) { + result.errors.push("Backup file is empty"); + return result; } - // Step 2: Parse JSON + // Step 2: Try to parse JSON directly first let parsedData; try { parsedData = JSON.parse(jsonString); } catch (parseError) { - result.errors.push(`JSON parsing failed: ${parseError.message}`); - return result; - } + result.warnings.push(`Initial JSON parsing failed: ${parseError.message}`); - // Step 3: Validate structure and filter out corrupted objects if needed - const structureValidation = this._validateBackupStructure(parsedData); - if (!structureValidation.isValid) { - result.errors.push(...structureValidation.errors); - } - result.warnings.push(...structureValidation.warnings); - - // If we had to omit corrupted objects, filter the data to only include valid ones - if ( - Array.isArray(parsedData) && - structureValidation.warnings.some((w) => w.includes("omitted")) - ) { - const filteredData = parsedData.filter((item) => this._isValidCippObject(item)); - if (filteredData.length > 0) { - result.data = filteredData; - result.repaired = true; - result.warnings.push( - `Filtered backup data: ${filteredData.length} valid objects retained` - ); + // Step 3: Try basic repair + const repairResult = this._attemptBasicRepair(jsonString); + if (repairResult.success) { + try { + parsedData = JSON.parse(repairResult.repairedJson); + result.repaired = true; + result.warnings.push("Backup file was repaired during validation"); + } catch (secondParseError) { + result.errors.push( + `JSON parsing failed even after repair: ${secondParseError.message}` + ); + return result; + } } else { - result.errors.push("No valid objects remaining after filtering corrupted data"); + result.errors.push(...repairResult.errors); return result; } - } else { - result.data = parsedData; } - // Step 4: Validate data integrity - const integrityValidation = this._validateDataIntegrity(result.data || parsedData); - if (!integrityValidation.isValid) { - result.warnings.push(...integrityValidation.warnings); - } - - result.isValid = result.errors.length === 0; - if (!result.data) { - result.data = parsedData; + // Step 4: Validate we have importable data + const dataValidation = this._validateImportableData(parsedData); + result.data = dataValidation.cleanData; + result.validRows = dataValidation.validRows; + result.totalRows = dataValidation.totalRows; + result.warnings.push(...dataValidation.warnings); + + // Accept the backup if we have at least some valid rows + if (dataValidation.validRows > 0) { + result.isValid = true; + if (dataValidation.skippedRows > 0) { + result.warnings.push( + `${dataValidation.skippedRows} corrupted rows will be skipped during import` + ); + } + } else { + result.errors.push("No valid rows found for import"); } } catch (error) { result.errors.push(`Validation failed: ${error.message}`); @@ -123,54 +87,9 @@ export const BackupValidator = { }, /** - * Performs basic validation checks on the raw JSON string + * Attempts basic repair of common JSON issues */ - _performBasicValidation(jsonString) { - const result = { isValid: true, errors: [] }; - - // Check if string is empty or null - if (!jsonString || jsonString.trim().length === 0) { - result.isValid = false; - result.errors.push("Backup file is empty"); - return result; - } - - // Check for basic JSON structure - const trimmed = jsonString.trim(); - if (!trimmed.startsWith("[") && !trimmed.startsWith("{")) { - result.isValid = false; - result.errors.push("Invalid JSON format: must start with [ or {"); - } - - if (!trimmed.endsWith("]") && !trimmed.endsWith("}")) { - result.isValid = false; - result.errors.push("Invalid JSON format: must end with ] or }"); - } - - // Check for common corruption patterns - const corruptionPatterns = [ - { pattern: /\\\",\"val\w*\":\s*$/, description: "Truncated escape sequences detected" }, - { pattern: /"[^"]*\n[^"]*"/, description: "Unescaped newlines in strings detected" }, - { pattern: /\{[^}]*$/, description: "Unclosed object brackets detected" }, - { pattern: /\[[^\]]*$/, description: "Unclosed array brackets detected" }, - { pattern: /\"[^"]*\\$/, description: "Incomplete escape sequences detected" }, - { pattern: /,\s*[}\]]/, description: "Trailing commas detected" }, - ]; - - for (const { pattern, description } of corruptionPatterns) { - if (pattern.test(jsonString)) { - result.isValid = false; - result.errors.push(description); - } - } - - return result; - }, - - /** - * Attempts to repair common JSON corruption issues - */ - _attemptRepair(jsonString) { + _attemptBasicRepair(jsonString) { const result = { success: false, repairedJson: jsonString, errors: [] }; try { @@ -179,63 +98,34 @@ export const BackupValidator = { // Fix 1: Remove trailing commas repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - // Fix 2: Fix common escape sequence issues - repaired = repaired.replace(/\\",\\"val/g, '\\",\\"value'); - - // Fix 2.5: Fix unescaped newlines within JSON strings - // Escape literal newline characters within quoted strings to make them valid JSON - repaired = repaired.replace(/"([^"]*)\n([^"]*)"/g, '"$1\\n$2"'); - - // Fix 3: Try to close unclosed brackets (basic attempt) + // Fix 2: Basic bracket closure (skip newline repair for now) const openBraces = (repaired.match(/\{/g) || []).length; const closeBraces = (repaired.match(/\}/g) || []).length; const openBrackets = (repaired.match(/\[/g) || []).length; const closeBrackets = (repaired.match(/\]/g) || []).length; - // Add missing closing braces/brackets if the difference is reasonable (< 5) - if (openBraces - closeBraces > 0 && openBraces - closeBraces < 5) { - repaired += "}}".repeat(openBraces - closeBraces); + if (openBraces - closeBraces > 0 && openBraces - closeBraces < 3) { + repaired += "}".repeat(openBraces - closeBraces); } - if (openBrackets - closeBrackets > 0 && openBrackets - closeBrackets < 5) { + if (openBrackets - closeBrackets > 0 && openBrackets - closeBrackets < 3) { repaired += "]".repeat(openBrackets - closeBrackets); } - // Fix 4: Handle corrupted JWT/Base64 tokens that get mixed with other data - // Pattern: Base64 string followed by unexpected characters then "label" - repaired = repaired.replace(/([A-Za-z0-9+/=]{50,})"([^"]*)"label/g, '$1"}}, {\\"label'); - - // Fix 5: Handle broken string continuation patterns - repaired = repaired.replace(/"([^"]*)"([A-Za-z]+)":/g, '"$1"},"$2":'); - - // Fix 6: Handle truncated strings that jump to new fields - // Pattern: incomplete string followed by field name without proper closure - repaired = repaired.replace(/([^\\])"([a-zA-Z_][a-zA-Z0-9_]*)":/g, '$1","$2":'); - - // Fix 7: Remove invalid trailing characters after last valid JSON structure - const lastBraceIndex = repaired.lastIndexOf("}"); - const lastBracketIndex = repaired.lastIndexOf("]"); - const lastValidIndex = Math.max(lastBraceIndex, lastBracketIndex); - - if (lastValidIndex > 0 && lastValidIndex < repaired.length - 1) { - const afterLastValid = repaired.substring(lastValidIndex + 1).trim(); - // If there's significant content after the last valid structure, it might be corruption - if (afterLastValid.length > 10 && !afterLastValid.match(/^[\s,]*$/)) { - repaired = repaired.substring(0, lastValidIndex + 1); - } - } - - // Fix 8: Advanced corruption repair - try to find and isolate corrupted entries - if (!this._isValidJson(repaired)) { - repaired = this._attemptAdvancedRepair(repaired); - } - - // Try to parse the repaired JSON + // Test if repair worked try { JSON.parse(repaired); result.success = true; result.repairedJson = repaired; } catch (parseError) { - result.errors.push(`Repair attempt failed: ${parseError.message}`); + // If basic repair failed, try advanced repair for corrupted entries + const advancedResult = this._attemptAdvancedRepair(repaired, parseError); + if (advancedResult.success) { + result.success = true; + result.repairedJson = advancedResult.repairedJson; + } else { + result.errors.push(`Basic repair failed: ${parseError.message}`); + result.errors.push(...advancedResult.errors); + } } } catch (error) { result.errors.push(`Repair process failed: ${error.message}`); @@ -245,551 +135,242 @@ export const BackupValidator = { }, /** - * Helper function to check if a string is valid JSON + * Advanced repair for severely corrupted entries + * Attempts to isolate and fix/remove corrupted entries that break the entire JSON */ - _isValidJson(str) { - try { - JSON.parse(str); - return true; - } catch { - return false; - } - }, + _attemptAdvancedRepair(jsonString, parseError) { + const result = { success: false, repairedJson: jsonString, errors: [] }; - /** - * Advanced repair for severely corrupted JSON - */ - _attemptAdvancedRepair(jsonString) { try { - // Strategy 1: Try to extract individual valid JSON objects from the corrupted string - // using a robust pattern matching approach that finds complete objects - - const validObjects = []; - let corruptedCount = 0; - - // Method 1: Look for PartitionKey patterns and extract complete objects - const partitionKeyPattern = /"PartitionKey"\s*:\s*"[^"]*"/g; - let partitionMatch; - - while ((partitionMatch = partitionKeyPattern.exec(jsonString)) !== null) { - try { - // Find the object that contains this PartitionKey - const startPos = jsonString.lastIndexOf("{", partitionMatch.index); - if (startPos === -1) continue; - - // Count braces to find the complete object - let braceCount = 0; - let endPos = startPos; - - for (let i = startPos; i < jsonString.length; i++) { - if (jsonString[i] === "{") braceCount++; - if (jsonString[i] === "}") braceCount--; - - if (braceCount === 0) { - endPos = i; - break; - } - } - - if (braceCount === 0) { - const candidateObject = jsonString.substring(startPos, endPos + 1); - - // Clean up common issues more aggressively - let cleanObject = candidateObject - .replace(/,(\s*[}\]])/g, "$1") // Remove trailing commas - .replace(/([^\\])\\n/g, "$1\\\\n") // Fix unescaped newlines - .replace(/\n/g, " ") // Replace actual newlines with spaces - .replace(/([^"\\])\\"([^"]*)\\"([^"\\])/g, '$1"$2"$3') // Fix over-escaped quotes - .replace(/\\\\/g, "\\"); // Fix double escaping - - try { - const obj = JSON.parse(cleanObject); - if (obj.PartitionKey && obj.RowKey && this._isValidCippObject(obj)) { - // Check if we already added this object (avoid duplicates) - const duplicate = validObjects.find( - (existing) => - existing.PartitionKey === obj.PartitionKey && existing.RowKey === obj.RowKey - ); - if (!duplicate) { - validObjects.push(obj); - } - } else { - corruptedCount++; - } - } catch (firstError) { - // Try more aggressive repair before giving up - try { - let aggressiveRepair = candidateObject - .replace(/,(\s*[}\]])/g, "$1") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/\t/g, "\\t") - .replace(/([^\\])"/g, '$1\\"') // More aggressive quote escaping - .replace(/\\\\"$/, '"'); // Fix ending quotes - - const obj2 = JSON.parse(aggressiveRepair); - if (obj2.PartitionKey && obj2.RowKey) { - const duplicate = validObjects.find( - (existing) => - existing.PartitionKey === obj2.PartitionKey && existing.RowKey === obj2.RowKey - ); - if (!duplicate) { - validObjects.push(obj2); - } - } else { - corruptedCount++; - } - } catch (secondError) { - corruptedCount++; - } - } - } - } catch { - corruptedCount++; - } - } - - if (validObjects.length > 0) { - console.log( - `Recovered ${validObjects.length} valid objects, omitted ${corruptedCount} corrupted objects` - ); - return JSON.stringify(validObjects); - } - - // Strategy 2: Try line-by-line parsing for objects that span multiple lines - const lines = jsonString.split("\n"); - let currentObject = ""; - let braceCount = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - if (line.startsWith('{"PartitionKey"')) { - currentObject = line; - braceCount = (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length; - } else if (currentObject && braceCount > 0) { - currentObject += line; - braceCount += (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length; - } - - // If we have a complete object (braces balanced) - if (currentObject && braceCount === 0) { - try { - // Remove trailing comma if present - const cleanObject = currentObject.replace(/,$/, ""); - const obj = JSON.parse(cleanObject); - - if (obj.PartitionKey && obj.RowKey && this._isValidCippObject(obj)) { - validObjects.push(obj); - } else { - corruptedCount++; - } - } catch { - corruptedCount++; - } - - currentObject = ""; + // If the error message indicates a specific position, try to isolate the corruption + const positionMatch = parseError.message.match(/position (\d+)/); + if (positionMatch) { + const errorPosition = parseInt(positionMatch[1]); + result.errors.push(`Attempting to repair corruption at position ${errorPosition}`); + + // Strategy 1: Try to find and isolate the corrupted entry + const isolatedResult = this._isolateCorruptedEntry(jsonString, errorPosition); + if (isolatedResult.success) { + result.success = true; + result.repairedJson = isolatedResult.repairedJson; + return result; } + result.errors.push(...isolatedResult.errors); } - if (validObjects.length > 0) { - console.log( - `Line-by-line recovery: ${validObjects.length} valid objects, omitted ${corruptedCount} corrupted objects` - ); - return JSON.stringify(validObjects); + // Strategy 2: Try to extract valid entries before corruption + const truncateResult = this._extractValidEntries(jsonString, parseError); + if (truncateResult.success) { + result.success = true; + result.repairedJson = truncateResult.repairedJson; + return result; } + result.errors.push(...truncateResult.errors); + } catch (error) { + result.errors.push(`Advanced repair failed: ${error.message}`); + } - // Strategy 3: More aggressive pattern matching for partial objects - console.log("Attempting aggressive pattern-based recovery..."); - - // First, try to fix common newline issues in the original string - let cleanedJson = jsonString; + return result; + }, - // Fix unescaped newlines within property values (but preserve structural JSON) - cleanedJson = cleanedJson.replace(/"([^"]*)\n([^"]*)"/g, '"$1\\n$2"'); + /** + * Attempts to isolate and remove/fix a corrupted entry + */ + _isolateCorruptedEntry(jsonString, errorPosition) { + const result = { success: false, repairedJson: jsonString, errors: [] }; - // Try parsing the cleaned version first - try { - const cleanedParsed = JSON.parse(cleanedJson); - if (Array.isArray(cleanedParsed)) { - const validCleanedObjects = cleanedParsed.filter((obj) => this._isValidCippObject(obj)); - if (validCleanedObjects.length > 0) { - console.log(`Newline repair: ${validCleanedObjects.length} objects recovered`); - return JSON.stringify(validCleanedObjects); - } - } - } catch (e) { - // Continue with aggressive pattern matching - } + try { + // Find the object that contains the corruption + const beforeError = jsonString.substring(0, errorPosition); + const afterError = jsonString.substring(errorPosition); - const aggressivePattern = - /"PartitionKey"\s*:\s*"([^"]*)"[\s\S]*?"RowKey"\s*:\s*"([^"]*)"[\s\S]*?(?="PartitionKey"|$)/g; - let aggressiveMatch; + // Look for the last complete object boundary before the error + const lastObjectStart = beforeError.lastIndexOf('{\n "PartitionKey"'); + const nextObjectStart = afterError.indexOf('\n },\n {\n "PartitionKey"'); - while ((aggressiveMatch = aggressivePattern.exec(cleanedJson)) !== null) { - try { - const partitionKey = aggressiveMatch[1]; - const rowKey = aggressiveMatch[2]; - const candidateText = aggressiveMatch[0]; - - // Try to reconstruct a minimal valid object - const minimalObject = { - PartitionKey: partitionKey, - RowKey: rowKey, - }; - - // Extract additional fields using regex - const fieldPattern = /"([^"]+)"\s*:\s*"([^"]*)"/g; - let fieldMatch; - - while ((fieldMatch = fieldPattern.exec(candidateText)) !== null) { - const fieldName = fieldMatch[1]; - const fieldValue = fieldMatch[2]; - - // Skip the fields we already have and avoid problematic ones - if ( - fieldName !== "PartitionKey" && - fieldName !== "RowKey" && - fieldValue.length < 10000 && - !fieldValue.includes('"PartitionKey"') - ) { - minimalObject[fieldName] = fieldValue; - } - } + if (lastObjectStart !== -1 && nextObjectStart !== -1) { + const beforeCorrupted = jsonString.substring(0, lastObjectStart); + const afterCorrupted = jsonString.substring(errorPosition + nextObjectStart); - // Only add if we have at least 3 fields (PartitionKey, RowKey, and one more) - if (Object.keys(minimalObject).length >= 3) { - const duplicate = validObjects.find( - (existing) => - existing.PartitionKey === minimalObject.PartitionKey && - existing.RowKey === minimalObject.RowKey - ); - if (!duplicate) { - validObjects.push(minimalObject); - } - } - } catch { - // Skip this match - } - } + // Try to reconstruct without the corrupted entry + let repaired = beforeCorrupted + afterCorrupted; - if (validObjects.length > 0) { - console.log(`Aggressive recovery: ${validObjects.length} partial objects reconstructed`); - return JSON.stringify(validObjects); - } + // Clean up any resulting syntax issues + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + repaired = repaired.replace(/\{\s*,/g, "{"); + repaired = repaired.replace(/,\s*,/g, ","); - // Strategy 4: If that fails, try to truncate at the first major corruption point - for (let i = 0; i < lines.length; i++) { try { - const partialJson = lines.slice(0, i + 1).join("\n"); - // Try to fix basic bracket issues and test - let testJson = partialJson; - const openBraces = (testJson.match(/\{/g) || []).length; - const closeBraces = (testJson.match(/\}/g) || []).length; - const openBrackets = (testJson.match(/\[/g) || []).length; - const closeBrackets = (testJson.match(/\]/g) || []).length; - - // Add missing closures - if (openBraces > closeBraces) { - testJson += "}".repeat(openBraces - closeBraces); - } - if (openBrackets > closeBrackets) { - testJson += "]".repeat(openBrackets - closeBrackets); - } - - const parsed = JSON.parse(testJson); - if (Array.isArray(parsed) && parsed.length > 0) { - // Filter out any invalid objects from the truncated result - const validParsed = parsed.filter((obj) => this._isValidCippObject(obj)); - if (validParsed.length > 0) { - console.log(`Truncation recovery: ${validParsed.length} valid objects recovered`); - return JSON.stringify(validParsed); - } - } - } catch { - continue; + JSON.parse(repaired); + result.success = true; + result.repairedJson = repaired; + result.errors.push("Successfully isolated and removed corrupted entry"); + return result; + } catch (stillError) { + result.errors.push(`Isolation attempt failed: ${stillError.message}`); } } } catch (error) { - console.warn("Advanced repair failed:", error); + result.errors.push(`Corruption isolation failed: ${error.message}`); } - return jsonString; // Return original if all repairs fail + return result; }, /** - * Validates if an object is a valid CIPP backup object + * Extracts valid entries up to the point of corruption */ - _isValidCippObject(obj) { + _extractValidEntries(jsonString, parseError) { + const result = { success: false, repairedJson: jsonString, errors: [] }; + try { - // Basic structure validation - if (typeof obj !== "object" || obj === null) { - return false; + const positionMatch = parseError.message.match(/position (\d+)/); + if (!positionMatch) { + result.errors.push("Cannot determine corruption position"); + return result; } - // Required fields for CIPP backup objects - if (!obj.PartitionKey || !obj.RowKey) { - return false; - } + const errorPosition = parseInt(positionMatch[1]); + const beforeError = jsonString.substring(0, errorPosition); - // Check for obvious corruption patterns in string values - for (const [key, value] of Object.entries(obj)) { - if (typeof value === "string") { - // Check for common corruption indicators - if ( - value.includes("\0") || // Null bytes - value.length > 500000 - ) { - // Very large strings (>500KB) - return false; - } + // Find the last complete object before the error + const lastCompleteObject = beforeError.lastIndexOf("\n }"); - // More specific JWT token corruption pattern - only flag if clearly corrupted - // Look for Base64 that's truncated mid-token and followed by unescaped content - if (value.match(/[A-Za-z0-9+/=]{100,}[^A-Za-z0-9+/=\s].*"label"[^"]*"[^"]*"[a-zA-Z]/)) { - console.log(`Detected severe JWT corruption in ${key}: ${value.substring(0, 100)}...`); - return false; - } - - // Check for obvious object data mixing (but be more lenient) - if ( - (value.includes('"PartitionKey":"') && - value.includes('"RowKey":"') && - value.includes('"table":"')) || - value.match(/"PartitionKey":"[^"]*","RowKey":"[^"]*"/) - ) { - console.log(`Detected obvious object mixing in ${key}`); - return false; - } + if (lastCompleteObject !== -1) { + // Extract everything up to the last complete object + let validPortion = jsonString.substring(0, lastCompleteObject + 6); // Include the \n } - // Check for severely broken JSON structure within strings - if (value.includes('"},"') && value.includes('{"') && !this._looksLikeValidJson(value)) { - // Try to determine if this is actually corrupted or just complex nested JSON - const jsonChunks = value.split('"},'); - let validChunks = 0; - for (const chunk of jsonChunks.slice(0, 3)) { - // Check first few chunks - try { - JSON.parse(chunk + "}"); - validChunks++; - } catch { - // Invalid chunk - } - } - - // If most chunks are invalid, consider it corrupted - if (validChunks === 0 && jsonChunks.length > 1) { - console.log(`Detected broken JSON structure in ${key}`); - return false; - } - } + // Ensure proper JSON array closure + if (!validPortion.trim().endsWith("]")) { + validPortion += "\n]"; + } - // If it looks like JSON, try to parse it (but be more forgiving) - if (this._looksLikeJson(value)) { - try { - JSON.parse(value); - } catch (parseError) { - // Try some basic repairs before giving up - const repairedValue = this._attemptStringRepair(value); - try { - JSON.parse(repairedValue); - // If repair succeeded, continue with the object - } catch { - // Only reject if repair also failed - return false; - } - } + try { + const parsed = JSON.parse(validPortion); + if (Array.isArray(parsed) && parsed.length > 0) { + result.success = true; + result.repairedJson = validPortion; + result.errors.push(`Extracted ${parsed.length} valid entries before corruption`); + return result; } + } catch (stillError) { + result.errors.push(`Valid portion extraction failed: ${stillError.message}`); } } - - return true; - } catch { - return false; + } catch (error) { + result.errors.push(`Entry extraction failed: ${error.message}`); } - }, - - /** - * Attempt basic repairs on corrupted JSON strings - */ - _attemptStringRepair(str) { - let repaired = str; - - // Fix common issues - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); // Remove trailing commas - repaired = repaired.replace(/([^\\])\\n/g, "$1\\\\n"); // Fix unescaped newlines - repaired = repaired.replace(/\n/g, " "); // Replace actual newlines with spaces - repaired = repaired.replace(/([^\\])"/g, '$1\\"'); // Fix unescaped quotes (be careful) - - return repaired; - }, - - /** - * More strict validation for JSON-like strings - */ - _looksLikeValidJson(str) { - if (typeof str !== "string" || str.length < 2) return false; - const trimmed = str.trim(); - - // Must start and end with proper JSON delimiters - if (trimmed.startsWith("{") && trimmed.endsWith("}")) return true; - if (trimmed.startsWith("[") && trimmed.endsWith("]")) return true; - if (trimmed.startsWith('"') && trimmed.endsWith('"')) return true; - return false; + return result; }, /** - * Validates the structure of parsed backup data + * Validates that we have importable data rows + * Filters out corrupted entries but keeps valid ones */ - _validateBackupStructure(data) { - const result = { isValid: true, errors: [], warnings: [] }; + _validateImportableData(data) { + const result = { + cleanData: null, + validRows: 0, + totalRows: 0, + skippedRows: 0, + warnings: [], + }; - // Check if data is an array (expected format for CIPP backups) + // Handle non-array data if (!Array.isArray(data)) { - result.warnings.push("Backup data is not an array - may be an older format"); + if (data && typeof data === "object") { + // Single object - wrap in array + data = [data]; + result.warnings.push("Single object detected, converted to array format"); + } else { + result.warnings.push("Data is not in expected array format"); + result.cleanData = []; + return result; + } } - // If it's an array, validate each entry - if (Array.isArray(data)) { - let validCount = 0; - let invalidCount = 0; - - data.forEach((item, index) => { - if (typeof item !== "object" || item === null) { - result.warnings.push(`Item at index ${index} is not a valid object`); - invalidCount++; - return; - } - - // Check for required CIPP backup fields - const expectedFields = ["PartitionKey", "RowKey"]; - const missingFields = expectedFields.filter((field) => !(field in item)); + result.totalRows = data.length; + const cleanRows = []; - if (missingFields.length > 0) { - result.warnings.push( - `Item at index ${index} missing required fields: ${missingFields.join(", ")}` - ); - invalidCount++; - return; - } - - // Additional validation using the CIPP object validator - if (!this._isValidCippObject(item)) { - result.warnings.push(`Item at index ${index} contains corrupted data and was omitted`); - invalidCount++; - return; - } - - validCount++; - - // Check for corrupted JSON strings within valid objects - Object.keys(item).forEach((key) => { - const value = item[key]; - if (typeof value === "string" && this._looksLikeJson(value)) { - try { - JSON.parse(value); - } catch { - result.warnings.push( - `Item at index ${index}, field '${key}' contains invalid JSON but object was kept` - ); - } - } - }); - }); - - // Summary information - if (invalidCount > 0) { - result.warnings.push( - `Backup recovery summary: ${validCount} valid objects restored, ${invalidCount} corrupted objects omitted` - ); + // Check each row for importability + data.forEach((row, index) => { + if (this._isValidImportRow(row)) { + cleanRows.push(row); + result.validRows++; + } else { + result.skippedRows++; + result.warnings.push(`Row ${index + 1} skipped: ${this._getRowSkipReason(row)}`); } + }); - // Only mark as invalid if we have no valid objects at all - if (validCount === 0 && data.length > 0) { - result.isValid = false; - result.errors.push("No valid CIPP backup objects found"); - } - } - + result.cleanData = cleanRows; return result; }, /** - * Validates data integrity and completeness + * Checks if a row is valid for import into CIPP tables */ - _validateDataIntegrity(data) { - const result = { isValid: true, warnings: [] }; - - if (Array.isArray(data)) { - // Check for duplicate entries - const seen = new Set(); - const duplicates = []; - - data.forEach((item, index) => { - if (item.PartitionKey && item.RowKey) { - const key = `${item.PartitionKey}:${item.RowKey}`; - if (seen.has(key)) { - duplicates.push(`Duplicate entry at index ${index}: ${key}`); - } else { - seen.add(key); - } - } - }); + _isValidImportRow(row) { + // Must be an object + if (!row || typeof row !== "object") { + return false; + } - if (duplicates.length > 0) { - result.warnings.push(...duplicates); + // Must have either: + // 1. PartitionKey and RowKey (Azure Table format) + // 2. id field (some backup formats) + // 3. At least 2 string properties (fallback for partial data) + const hasAzureKeys = row.PartitionKey && row.RowKey; + const hasId = row.id; + const stringProps = Object.values(row).filter((v) => typeof v === "string").length; + + if (hasAzureKeys || hasId || stringProps >= 2) { + // Additional checks for obvious corruption + const rowJson = JSON.stringify(row); + + // Skip rows that are way too large (likely corrupted) + if (rowJson.length > 10000000) { + // 10MB limit + return false; } - // Check for suspiciously large entries (potential corruption) - data.forEach((item, index) => { - const itemSize = JSON.stringify(item).length; - if (itemSize > 50000) { - // 50KB threshold - result.warnings.push( - `Item at index ${index} is unusually large (${itemSize} bytes) - may be corrupted` - ); - } - }); - - // Basic statistics - const tables = data.map((item) => item.table).filter(Boolean); - const uniqueTables = [...new Set(tables)]; - - if (uniqueTables.length === 0) { - result.warnings.push("No table information found in backup"); + // Skip rows with null bytes (always corruption) + if (rowJson.includes("\0")) { + return false; } + + return true; } - return result; + return false; }, /** - * Helper function to detect if a string looks like JSON + * Gets a human-readable reason why a row was skipped */ - _looksLikeJson(str) { - if (typeof str !== "string" || str.length < 2) return false; - const trimmed = str.trim(); - return ( - (trimmed.startsWith("{") && trimmed.includes("}")) || - (trimmed.startsWith("[") && trimmed.includes("]")) - ); - }, + _getRowSkipReason(row) { + if (!row || typeof row !== "object") { + return "Not a valid object"; + } - /** - * Sanitizes backup data for display/logging purposes - */ - sanitizeForDisplay(data, maxLength = 100) { - if (typeof data === "string") { - return data.length > maxLength ? data.substring(0, maxLength) + "..." : data; + if (!row.PartitionKey && !row.RowKey && !row.id) { + const stringProps = Object.values(row).filter((v) => typeof v === "string").length; + if (stringProps < 2) { + return "Missing required identifiers and insufficient data"; + } } - try { - const jsonStr = JSON.stringify(data); - return jsonStr.length > maxLength ? jsonStr.substring(0, maxLength) + "..." : jsonStr; - } catch { - return "[Unserializable data]"; + const rowJson = JSON.stringify(row); + if (rowJson.length > 10000000) { + return "Row too large (likely corrupted)"; } + + if (rowJson.includes("\0")) { + return "Contains null bytes (corrupted)"; + } + + return "Unknown validation failure"; }, }; From a6ccff3ab0ce4775c53cc298905063e51600c937 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 5 Aug 2025 20:59:09 -0400 Subject: [PATCH 10/34] better validation of backup data --- src/pages/cipp/settings/backup.js | 12 ++++-- src/utils/backupValidation.js | 63 +++++++++++++++++-------------- 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/src/pages/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index d1fad91f241b..4c5249cf51ec 100644 --- a/src/pages/cipp/settings/backup.js +++ b/src/pages/cipp/settings/backup.js @@ -421,11 +421,17 @@ const Page = () => { {/* Backup Restore Confirmation Dialog */} { restoreDialog.handleClose(); + // Clear state when user manually closes the dialog setValidationResult(null); setSelectedBackupFile(null); setSelectedBackupData(null); @@ -439,10 +445,8 @@ const Page = () => { ? "Are you sure you want to restore this backup? This will overwrite your current CIPP configuration." : null, onSuccess: () => { - restoreDialog.handleClose(); - setValidationResult(null); - setSelectedBackupFile(null); - setSelectedBackupData(null); + // 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 }, }} relatedQueryKeys={["BackupList", "ScheduledBackup"]} diff --git a/src/utils/backupValidation.js b/src/utils/backupValidation.js index ae87a63c5f22..fe3880bd2558 100644 --- a/src/utils/backupValidation.js +++ b/src/utils/backupValidation.js @@ -317,33 +317,31 @@ export const BackupValidator = { return false; } - // Must have either: - // 1. PartitionKey and RowKey (Azure Table format) - // 2. id field (some backup formats) - // 3. At least 2 string properties (fallback for partial data) - const hasAzureKeys = row.PartitionKey && row.RowKey; - const hasId = row.id; - const stringProps = Object.values(row).filter((v) => typeof v === "string").length; - - if (hasAzureKeys || hasId || stringProps >= 2) { - // Additional checks for obvious corruption - const rowJson = JSON.stringify(row); - - // Skip rows that are way too large (likely corrupted) - if (rowJson.length > 10000000) { - // 10MB limit - return false; - } + // Must have all three required properties for CIPP table storage + const hasTable = row.table && typeof row.table === "string"; + const hasPartitionKey = row.PartitionKey && typeof row.PartitionKey === "string"; + const hasRowKey = row.RowKey && typeof row.RowKey === "string"; - // Skip rows with null bytes (always corruption) - if (rowJson.includes("\0")) { - return false; - } + // All three are required for valid CIPP backup row + if (!hasTable || !hasPartitionKey || !hasRowKey) { + return false; + } - return true; + // Additional checks for obvious corruption + const rowJson = JSON.stringify(row); + + // Skip rows that are way too large (likely corrupted) + if (rowJson.length > 10000000) { + // 10MB limit + return false; } - return false; + // Skip rows with null bytes (always corruption) + if (rowJson.includes("\0")) { + return false; + } + + return true; }, /** @@ -354,11 +352,20 @@ export const BackupValidator = { return "Not a valid object"; } - if (!row.PartitionKey && !row.RowKey && !row.id) { - const stringProps = Object.values(row).filter((v) => typeof v === "string").length; - if (stringProps < 2) { - return "Missing required identifiers and insufficient data"; - } + // Check for missing required CIPP backup properties + const missingFields = []; + if (!row.table || typeof row.table !== "string") { + missingFields.push("table"); + } + if (!row.PartitionKey || typeof row.PartitionKey !== "string") { + missingFields.push("PartitionKey"); + } + if (!row.RowKey || typeof row.RowKey !== "string") { + missingFields.push("RowKey"); + } + + if (missingFields.length > 0) { + return `Missing required fields: ${missingFields.join(", ")}`; } const rowJson = JSON.stringify(row); From dc6235baf061cb419b5527832e49eb26206b489a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:25:41 +0200 Subject: [PATCH 11/34] improve blocked from spam check --- src/components/CippCards/CippExchangeInfoCard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CippCards/CippExchangeInfoCard.jsx b/src/components/CippCards/CippExchangeInfoCard.jsx index 5994bf7922a5..df22ef79262f 100644 --- a/src/components/CippCards/CippExchangeInfoCard.jsx +++ b/src/components/CippCards/CippExchangeInfoCard.jsx @@ -60,7 +60,7 @@ export const CippExchangeInfoCard = (props) => { } /> - {exchangeData?.BlockedForSpam ? ( + {exchangeData?.BlockedForSpam === true ? ( This mailbox is currently blocked for spam. From e82be7f4abf763e643b53f405f5d1226c456b905 Mon Sep 17 00:00:00 2001 From: Zac Richards <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 6 Aug 2025 22:58:42 +0800 Subject: [PATCH 12/34] fix for managing mailbox rules in the user exchange view missing tenantfilter --- src/pages/identity/administration/users/user/exchange.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index d4f6014ecb4d..6cf9f325d18e 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -707,6 +707,7 @@ const Page = () => { userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, ruleName: row?.Name, Enable: true, + tenantFilter: userSettingsDefaults.currentTenant, }; }, condition: (row) => row && !row.Enabled, @@ -724,6 +725,7 @@ const Page = () => { userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, ruleName: row?.Name, Disable: true, + tenantFilter: userSettingsDefaults.currentTenant, }; }, condition: (row) => row && row.Enabled, @@ -740,6 +742,7 @@ const Page = () => { ruleId: row?.Identity, ruleName: row?.Name, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + tenantFilter: userSettingsDefaults.currentTenant, }; }, confirmText: "Are you sure you want to remove this mailbox rule?", @@ -803,6 +806,7 @@ const Page = () => { userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, ruleName: data?.Name, Enable: true, + tenantFilter: userSettingsDefaults.currentTenant, }, confirmText: "Are you sure you want to enable this mailbox rule?", multiPost: false, @@ -817,6 +821,7 @@ const Page = () => { userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, ruleName: data?.Name, Disable: true, + tenantFilter: userSettingsDefaults.currentTenant, }, confirmText: "Are you sure you want to disable this mailbox rule?", multiPost: false, @@ -830,6 +835,7 @@ const Page = () => { ruleId: data?.Identity, ruleName: data?.Name, userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + tenantFilter: userSettingsDefaults.currentTenant, }, confirmText: "Are you sure you want to remove this mailbox rule?", multiPost: false, From 2ed84550fbf873eb5aaf1ea550c2ea385222599f Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:41:56 +0200 Subject: [PATCH 13/34] changes to deploy policy for intune and make catalog clearer. --- .../CippComponents/CippFormTenantSelector.jsx | 2 +- .../CippComponents/CippOffCanvas.jsx | 89 ++-- .../CippComponents/CippPolicyDeployDrawer.jsx | 214 +++++++++ .../CippComponents/CippPolicyImportDrawer.jsx | 407 ++++++++++++++++++ src/components/CippFormPages/CippJSONView.jsx | 2 +- src/layouts/config.js | 5 - .../MEM/list-appprotection-policies/index.js | 15 +- .../MEM/list-compliance-policies/index.js | 15 +- src/pages/endpoint/MEM/list-policies/index.js | 15 +- .../endpoint/MEM/list-templates/index.js | 29 +- .../tenant/conditional/list-template/index.js | 7 +- 11 files changed, 726 insertions(+), 74 deletions(-) create mode 100644 src/components/CippComponents/CippPolicyDeployDrawer.jsx create mode 100644 src/components/CippComponents/CippPolicyImportDrawer.jsx diff --git a/src/components/CippComponents/CippFormTenantSelector.jsx b/src/components/CippComponents/CippFormTenantSelector.jsx index 262f85e677ad..5b63ae112ad6 100644 --- a/src/components/CippComponents/CippFormTenantSelector.jsx +++ b/src/components/CippComponents/CippFormTenantSelector.jsx @@ -75,7 +75,7 @@ export const CippFormTenantSelector = ({ name={name} formControl={formControl} preselectedValue={preselectedEnabled ?? currentTenant ? currentTenant : null} - placeholder="Select a tenant" + label="Select a tenant" creatable={false} multiple={type === "single" ? false : true} disableClearable={disableClearable} diff --git a/src/components/CippComponents/CippOffCanvas.jsx b/src/components/CippComponents/CippOffCanvas.jsx index caa30e6b3036..b39c2192e1ea 100644 --- a/src/components/CippComponents/CippOffCanvas.jsx +++ b/src/components/CippComponents/CippOffCanvas.jsx @@ -1,4 +1,4 @@ -import { Drawer, Box, IconButton } from "@mui/material"; +import { Drawer, Box, IconButton, Typography, Divider } from "@mui/material"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; @@ -16,6 +16,7 @@ export const CippOffCanvas = (props) => { isFetching, children, size = "sm", + footer, } = props; const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); @@ -79,41 +80,65 @@ export const CippOffCanvas = (props) => { open={visible} onClose={onClose} > - - - - {/* Force vertical stacking in a column layout */} + {title} + + + + + + {/* Main content area */} - - - {extendedInfo.length > 0 && ( - - )} + + + + {extendedInfo.length > 0 && ( + + )} + + + + {/* Render children if provided, otherwise render default content */} + {typeof children === "function" ? children(extendedData) : children} + + - - - {typeof children === "function" ? children(extendedData) : children} - - - + + + {/* Footer section */} + {footer && ( + + {footer} + + )} diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx new file mode 100644 index 000000000000..a92ccda882d0 --- /dev/null +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -0,0 +1,214 @@ +import { useEffect, useState } from "react"; +import { Button, Stack, Box } from "@mui/material"; +import { RocketLaunch } from "@mui/icons-material"; +import { useForm, useWatch } from "react-hook-form"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { CippIntunePolicy } from "../CippWizard/CippIntunePolicy"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import CippFormComponent from "./CippFormComponent"; +import CippJsonView from "../CippFormPages/CippJSONView"; +import { Grid } from "@mui/system"; +import { CippFormCondition } from "./CippFormCondition"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; + +export const CippPolicyDeployDrawer = ({ + buttonText = "Deploy Policy", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm(); + const tenantFilter = useSettings()?.tenantFilter; + const selectedTenants = useWatch({ control: formControl.control, name: "tenantFilter" }) || []; + const CATemplates = ApiGetCall({ url: "/api/ListIntuneTemplates", queryKey: "IntuneTemplates" }); + const [JSONData, setJSONData] = useState(); + const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); + const jsonWatch = useWatch({ control: formControl.control, name: "RAWJson" }); + useEffect(() => { + if (CATemplates.isSuccess && watcher?.value) { + const template = CATemplates.data.find((template) => template.GUID === watcher.value); + if (template) { + const jsonTemplate = template.RAWJson ? JSON.parse(template.RAWJson) : null; + setJSONData(jsonTemplate); + formControl.setValue("RAWJson", template.RAWJson); + formControl.setValue("displayName", template.Displayname); + formControl.setValue("description", template.Description); + formControl.setValue("TemplateType", template.Type); + } + } + }, [watcher]); + const deployPolicy = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [ + "IntuneTemplates", + `Configuration Policies - ${tenantFilter}`, + `Compliance Policies - ${tenantFilter}`, + `Protection Policies - ${tenantFilter}`, + ], + }); + + const handleSubmit = () => { + const formData = formControl.getValues(); + console.log("Submitting form data:", formData); + deployPolicy.mutate({ + url: "/api/AddPolicy", + relatedQueryKeys: [ + "IntuneTemplates", + "Configuration Policies", + "Compliance Policies", + "Protection Policies", + ], + data: { ...formData }, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + ({ + label: template.Displayname, + value: template.GUID, + })) + : [] + } + /> + + + + + + + + + + + + + + {(() => { + const rawJson = jsonWatch ? jsonWatch : ""; + const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1]); + const uniquePlaceholders = Array.from(new Set(placeholderMatches)); + if (uniquePlaceholders.length === 0 || selectedTenants.length === 0) { + return null; + } + return uniquePlaceholders.map((placeholder) => ( + + {selectedTenants.map((tenant, idx) => ( + + ))} + + )); + })()} + + + + + + ); +}; diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx new file mode 100644 index 000000000000..8f971a102260 --- /dev/null +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -0,0 +1,407 @@ +import { useState } from "react"; +import { + Button, + Stack, + TextField, + Typography, + Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Skeleton, +} from "@mui/material"; +import { CloudUpload, Search, Visibility } from "@mui/icons-material"; +import { useForm, useWatch } from "react-hook-form"; +import { CippOffCanvas } from "./CippOffCanvas"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import CippFormComponent from "./CippFormComponent"; +import CippJsonView from "../CippFormPages/CippJSONView"; +import { CippApiResults } from "./CippApiResults"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; + +export const CippPolicyImportDrawer = ({ + buttonText = "Browse Catalog", + requiredPermissions = [], + PermissionButton = Button, + mode = "Intune", +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [viewDialogOpen, setViewDialogOpen] = useState(false); + const [viewingPolicy, setViewingPolicy] = useState(null); + const formControl = useForm(); + + const selectedSource = useWatch({ control: formControl.control, name: "policySource" }); + const tenantFilter = useWatch({ control: formControl.control, name: "tenantFilter" }); + + // API calls + const communityRepos = ApiGetCall({ + url: "/api/ListCommunityRepos", + queryKey: "CommunityRepos-List", + }); + + const tenantPolicies = ApiGetCall({ + url: + mode === "ConditionalAccess" + ? `/api/ListCATemplates?TenantFilter=${tenantFilter?.value || ""}` + : `/api/ListIntunePolicy?type=ESP&TenantFilter=${tenantFilter?.value || ""}`, + queryKey: `TenantPolicies-${mode}-${tenantFilter?.value || "none"}`, + }); + + const repoPolicies = ApiGetCall({ + url: `/api/ExecGitHubAction?Action=GetFileTree&FullName=${ + selectedSource?.value || "" + }&Branch=main`, + queryKey: `RepoPolicies-${mode}-${selectedSource?.value || "none"}`, + }); + + const importPolicy = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: + mode === "ConditionalAccess" ? ["ListCATemplates-table"] : ["ListIntuneTemplates-table"], + }); + + const viewPolicyQuery = ApiPostCall({ + onResult: (resp) => { + let content = resp?.Results?.content?.trim() || "{}"; + content = content.replace( + /^[\u0000-\u001F\u007F-\u009F]+|[\u0000-\u001F\u007F-\u009F]+$/g, + "" + ); + try { + setViewingPolicy(JSON.parse(content)); + } catch (e) { + console.error("Invalid JSON content:", e); + setViewingPolicy({}); + } + }, + }); + + const handleImportPolicy = (policy) => { + if (!policy) return; + + try { + if (selectedSource?.value === "tenant") { + // For tenant policies, use appropriate API based on mode + if (mode === "ConditionalAccess") { + // For Conditional Access, convert RawJSON to object and send the contents + let policyData = policy; + + // If the policy has RawJSON, parse it and use that as the data + if (policy.RawJSON) { + try { + policyData = JSON.parse(policy.RawJSON); + } catch (e) { + console.error("Failed to parse RawJSON:", e); + policyData = policy; + } + } + + // Send the object contents directly with tenantFilter + const caTemplateData = { + tenantFilter: tenantFilter?.value, + ...policyData, + }; + + importPolicy.mutate({ + url: "/api/AddCATemplate", + data: caTemplateData, + }); + } else { + // For Intune policies, use existing format + importPolicy.mutate({ + url: "/api/AddIntuneTemplate", + data: { + tenantFilter: tenantFilter?.value, + ID: policy.id, + URLName: policy.URLName || "GroupPolicyConfigurations", + }, + }); + } + } else { + // For community repository files, use ExecCommunityRepo + importPolicy.mutate({ + url: "/api/ExecCommunityRepo", + data: { + tenantFilter: tenantFilter?.value || "AllTenants", + Action: "ImportTemplate", + FullName: selectedSource?.value, + Path: policy.path, + Branch: "main", + Type: mode, + }, + }); + } + } catch (error) { + console.error("Error importing policy:", error); + } + }; + + const handleViewPolicy = (policy) => { + if (!policy) return; + + try { + if (selectedSource?.value !== "tenant" && selectedSource?.value) { + // For community repository files, fetch the file content + viewPolicyQuery.mutate({ + url: "/api/ExecGitHubAction", + data: { + Action: "GetFileContents", + FullName: selectedSource.value, + Path: policy.path || "", + Branch: "main", + }, + }); + } else { + // For tenant policies, use the policy object directly + setViewingPolicy(policy || {}); + } + setViewDialogOpen(true); + } catch (error) { + console.error("Error viewing policy:", error); + } + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + setSearchQuery(""); + setViewingPolicy(null); + // Don't reset form at all to avoid any potential issues + }; + + const handleCloseViewDialog = () => { + setViewDialogOpen(false); + setViewingPolicy(null); + }; + + const formatPolicyName = (policy) => { + // Safety check + if (!policy) return "Unnamed Policy"; + + // For tenant policies, use displayName or name + if (policy.displayName || policy.name) { + return policy.displayName || policy.name; + } + + // For repository files, format the path nicely + if (policy.path) { + try { + // Remove file extension + let name = policy.path.replace(/\.(json|yaml|yml)$/i, ""); + + // Remove directory path, keep only filename + name = name.split("/").pop(); + + // Replace underscores with spaces and clean up + name = name.replace(/_/g, " "); + + // Remove common prefixes like "CIPP_" + name = name.replace(/^CIPP\s*/i, ""); + + // Capitalize first letter of each word + name = name.replace(/\b\w/g, (l) => l.toUpperCase()); + + return name || "Unnamed Policy"; + } catch (error) { + console.warn("Error formatting policy name:", error); + return policy.path || "Unnamed Policy"; + } + } + + return "Unnamed Policy"; + }; + + // Get policies based on source + let availablePolicies = []; + if (selectedSource?.value === "tenant" && tenantPolicies.isSuccess && tenantFilter?.value) { + availablePolicies = Array.isArray(tenantPolicies.data) ? tenantPolicies.data : []; + } else if ( + selectedSource?.value && + selectedSource?.value !== "tenant" && + repoPolicies.isSuccess + ) { + const repoData = repoPolicies.data?.Results || repoPolicies.data || []; + availablePolicies = Array.isArray(repoData) ? repoData : []; + } + + const filteredPolicies = (() => { + if (!Array.isArray(availablePolicies)) return []; + + if (!searchQuery?.trim()) return availablePolicies; + + return availablePolicies.filter((policy) => { + if (!policy) return false; + const searchLower = searchQuery.toLowerCase(); + return ( + policy.displayName?.toLowerCase().includes(searchLower) || + policy.description?.toLowerCase().includes(searchLower) || + policy.name?.toLowerCase().includes(searchLower) || + policy.path?.toLowerCase().includes(searchLower) + ); + }); + })(); + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + } + > + + ({ + label: `${repo?.Name || "Unknown"} (${repo?.URL || "Unknown"})`, + value: repo?.FullName || "", + })).filter((option) => option.value) + : []), + { label: "Get template from existing tenant", value: "tenant" }, + ]} + /> + + {selectedSource?.value === "tenant" && ( + + )} + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: , + }} + placeholder="Search by policy name or description..." + /> + + Available Policies ({filteredPolicies.length}) + + {/* Loading skeletons */} + {(selectedSource?.value === "tenant" && tenantPolicies.isLoading) || + (selectedSource?.value && + selectedSource?.value !== "tenant" && + repoPolicies.isLoading) ? ( + <> + {[...Array(3)].map((_, index) => ( + + + + + + + + + ))} + + ) : Array.isArray(filteredPolicies) && filteredPolicies.length > 0 ? ( + filteredPolicies.map((policy, index) => { + if (!policy) return null; + return ( + + + + + + + {formatPolicyName(policy)} + + {policy?.description && ( + + {policy.description} + + )} + + + + ); + }) + ) : ( + + No policies available. + + )} + + {/* Error handling */} + {selectedSource?.value === "tenant" && ( + + )} + {selectedSource?.value && selectedSource?.value !== "tenant" && ( + + )} + + + + + + + Policy Details + + {viewPolicyQuery.isPending ? ( + + + + ) : ( + + )} + + + + + + + ); +}; diff --git a/src/components/CippFormPages/CippJSONView.jsx b/src/components/CippFormPages/CippJSONView.jsx index f6d206703e14..2cd814d427b7 100644 --- a/src/components/CippFormPages/CippJSONView.jsx +++ b/src/components/CippFormPages/CippJSONView.jsx @@ -433,7 +433,7 @@ function CippJsonView({ "createdDateTime", "modifiedDateTime", ]; - const cleanedObj = cleanObject(object); + const cleanedObj = cleanObject(object) || {}; const filteredObj = Object.fromEntries( Object.entries(cleanedObj).filter(([key]) => !blacklist.includes(key)) ); diff --git a/src/layouts/config.js b/src/layouts/config.js index 812c19383a8d..196dcffb8942 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -447,11 +447,6 @@ export const nativeMenuItems = [ path: "/endpoint/MEM/list-appprotection-policies", permissions: ["Endpoint.MEM.*"], }, - { - title: "Apply Policy", - path: "/endpoint/MEM/add-policy", - permissions: ["Endpoint.MEM.*"], - }, { title: "Policy Templates", path: "/endpoint/MEM/list-templates", diff --git a/src/pages/endpoint/MEM/list-appprotection-policies/index.js b/src/pages/endpoint/MEM/list-appprotection-policies/index.js index b637aa950730..2ec6781e2fa4 100644 --- a/src/pages/endpoint/MEM/list-appprotection-policies/index.js +++ b/src/pages/endpoint/MEM/list-appprotection-policies/index.js @@ -1,9 +1,9 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Book, RocketLaunch } from "@mui/icons-material"; +import { Book } from "@mui/icons-material"; import { TrashIcon } from "@heroicons/react/24/outline"; import { PermissionButton } from "/src/utils/permissions.js"; -import Link from "next/link"; +import { CippPolicyDeployDrawer } from "/src/components/CippComponents/CippPolicyDeployDrawer.jsx"; const Page = () => { const pageTitle = "App Protection & Configuration Policies"; @@ -62,14 +62,11 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={ - } - > - Deploy Policy - + PermissionButton={PermissionButton} + /> } /> ); diff --git a/src/pages/endpoint/MEM/list-compliance-policies/index.js b/src/pages/endpoint/MEM/list-compliance-policies/index.js index a2bb8eb989d1..d27b7113129d 100644 --- a/src/pages/endpoint/MEM/list-compliance-policies/index.js +++ b/src/pages/endpoint/MEM/list-compliance-policies/index.js @@ -1,9 +1,9 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Book, LaptopChromebook, RocketLaunch } from "@mui/icons-material"; +import { Book, LaptopChromebook } from "@mui/icons-material"; import { GlobeAltIcon, TrashIcon, UserIcon } from "@heroicons/react/24/outline"; import { PermissionButton } from "/src/utils/permissions.js"; -import Link from "next/link"; +import { CippPolicyDeployDrawer } from "/src/components/CippComponents/CippPolicyDeployDrawer.jsx"; const Page = () => { const pageTitle = "Intune Compliance Policies"; @@ -103,14 +103,11 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={ - } - > - Deploy Policy - + PermissionButton={PermissionButton} + /> } /> ); diff --git a/src/pages/endpoint/MEM/list-policies/index.js b/src/pages/endpoint/MEM/list-policies/index.js index a59547b8cbf7..bf23a754fb27 100644 --- a/src/pages/endpoint/MEM/list-policies/index.js +++ b/src/pages/endpoint/MEM/list-policies/index.js @@ -1,9 +1,9 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Book, LaptopChromebook, RocketLaunch } from "@mui/icons-material"; +import { Book, LaptopChromebook } from "@mui/icons-material"; import { GlobeAltIcon, TrashIcon, UserIcon } from "@heroicons/react/24/outline"; import { PermissionButton } from "/src/utils/permissions.js"; -import Link from "next/link"; +import { CippPolicyDeployDrawer } from "/src/components/CippComponents/CippPolicyDeployDrawer.jsx"; const Page = () => { const pageTitle = "Configuration Policies"; @@ -102,14 +102,11 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={ - } - > - Deploy Policy - + PermissionButton={PermissionButton} + /> } /> ); diff --git a/src/pages/endpoint/MEM/list-templates/index.js b/src/pages/endpoint/MEM/list-templates/index.js index 0bc90907c397..d5e8fa18e5e5 100644 --- a/src/pages/endpoint/MEM/list-templates/index.js +++ b/src/pages/endpoint/MEM/list-templates/index.js @@ -4,9 +4,13 @@ import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; import { Edit, GitHub } from "@mui/icons-material"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; import { ApiGetCall } from "/src/api/ApiCall"; +import { CippPolicyImportDrawer } from "/src/components/CippComponents/CippPolicyImportDrawer.jsx"; +import { PermissionButton } from "/src/utils/permissions.js"; const Page = () => { const pageTitle = "Available Endpoint Manager Templates"; + const cardButtonPermissions = ["Endpoint.MEM.ReadWrite"]; + const integrations = ApiGetCall({ url: "/api/ListExtensionsConfig", queryKey: "Integrations", @@ -107,13 +111,24 @@ const Page = () => { const simpleColumns = ["displayName", "description", "Type"]; return ( - + <> + + } + /> + ); }; diff --git a/src/pages/tenant/conditional/list-template/index.js b/src/pages/tenant/conditional/list-template/index.js index e53684b10849..e5387c6ee264 100644 --- a/src/pages/tenant/conditional/list-template/index.js +++ b/src/pages/tenant/conditional/list-template/index.js @@ -5,6 +5,7 @@ import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; import { Delete, GitHub, Edit } from "@mui/icons-material"; import { ApiGetCall } from "/src/api/ApiCall"; import Link from "next/link"; +import { CippPolicyImportDrawer } from "/src/components/CippComponents/CippPolicyImportDrawer.jsx"; const Page = () => { const pageTitle = "Available Conditional Access Templates"; @@ -84,11 +85,15 @@ const Page = () => { + <> + + + + } + > + + + + ({ + label: template.displayName, + value: template.GUID, + })) + : [] + } + /> + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/pages/tenant/conditional/list-policies/index.js b/src/pages/tenant/conditional/list-policies/index.js index 0feb27282419..be83cfaad658 100644 --- a/src/pages/tenant/conditional/list-policies/index.js +++ b/src/pages/tenant/conditional/list-policies/index.js @@ -12,6 +12,7 @@ import { import { Box, Button } from "@mui/material"; import Link from "next/link"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; +import { CippCADeployDrawer } from "../../../../components/CippComponents/CippCADeployDrawer"; // Page Component const Page = () => { @@ -158,13 +159,7 @@ const Page = () => { - + } title={pageTitle} From 0e7cc9aaf8a2db3f6b32993cdb0d2e0b1a7b31b5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 Aug 2025 11:47:22 +0200 Subject: [PATCH 15/34] cleanup --- src/components/CippWizard/CippCAForm.jsx | 103 ------------------ src/pages/tenant/conditional/deploy/index.js | 17 --- .../conditional/list-policies/deploy.js | 36 ------ 3 files changed, 156 deletions(-) delete mode 100644 src/components/CippWizard/CippCAForm.jsx delete mode 100644 src/pages/tenant/conditional/deploy/index.js delete mode 100644 src/pages/tenant/conditional/list-policies/deploy.js diff --git a/src/components/CippWizard/CippCAForm.jsx b/src/components/CippWizard/CippCAForm.jsx deleted file mode 100644 index d0ee1a657186..000000000000 --- a/src/components/CippWizard/CippCAForm.jsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Stack } from "@mui/material"; -import { CippWizardStepButtons } from "./CippWizardStepButtons"; -import CippJsonView from "../CippFormPages/CippJSONView"; -import CippFormComponent from "../CippComponents/CippFormComponent"; -import { ApiGetCall } from "../../api/ApiCall"; -import { useEffect, useState } from "react"; -import { useWatch } from "react-hook-form"; - -export const CippCAForm = (props) => { - const { formControl, onPreviousStep, onNextStep, currentStep } = props; - const values = formControl.getValues(); - const CATemplates = ApiGetCall({ url: "/api/ListCATemplates", queryKey: "CATemplates" }); - const [JSONData, setJSONData] = useState(); - const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); - useEffect(() => { - if (CATemplates.isSuccess && watcher?.value) { - const template = CATemplates.data.find((template) => template.GUID === watcher.value); - if (template) { - setJSONData(template); - formControl.setValue("rawjson", JSON.stringify(template, null)); - } - } - }, [CATemplates, watcher]); - - return ( - - - ({ - label: template.displayName, - value: template.GUID, - })) - : [] - } - /> - - - - - - - - - - - - - - - ); -}; diff --git a/src/pages/tenant/conditional/deploy/index.js b/src/pages/tenant/conditional/deploy/index.js deleted file mode 100644 index 1f9bf4feea32..000000000000 --- a/src/pages/tenant/conditional/deploy/index.js +++ /dev/null @@ -1,17 +0,0 @@ - -import { Layout as DashboardLayout } from "/src/layouts/index.js"; - -const Page = () => { - const pageTitle = "Deploy CA Policies"; - - return ( -
    -

    {pageTitle}

    -

    This is a placeholder page for the deploy ca policies section.

    -
    - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/pages/tenant/conditional/list-policies/deploy.js b/src/pages/tenant/conditional/list-policies/deploy.js deleted file mode 100644 index 4c6639f3a94d..000000000000 --- a/src/pages/tenant/conditional/list-policies/deploy.js +++ /dev/null @@ -1,36 +0,0 @@ -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import { CippWizardConfirmation } from "/src/components/CippWizard/CippWizardConfirmation"; -import CippWizardPage from "/src/components/CippWizard/CippWizardPage.jsx"; -import { CippTenantStep } from "../../../../components/CippWizard/CippTenantStep"; -import { CippCAForm } from "../../../../components/CippWizard/CippCAForm"; - -const Page = () => { - const steps = [ - { - title: "Step 1", - description: "Tenant Selection", - component: CippTenantStep, - componentProps: { type: "multiple" }, - }, - { - title: "Step 2", - description: "Conditional Access Configuration", - component: CippCAForm, - }, - { - title: "Step 3", - description: "Confirmation", - component: CippWizardConfirmation, - }, - ]; - - return ( - <> - - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; From ec851e11e2fe317b74aa05675e38c2c529580ef6 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:01:40 +0200 Subject: [PATCH 16/34] moved add application to drawer --- .../CippApplicationDeployDrawer.jsx | 777 ++++++++++++++++++ src/pages/endpoint/applications/list/add.jsx | 725 ---------------- src/pages/endpoint/applications/list/index.js | 9 +- 3 files changed, 780 insertions(+), 731 deletions(-) create mode 100644 src/components/CippComponents/CippApplicationDeployDrawer.jsx delete mode 100644 src/pages/endpoint/applications/list/add.jsx diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx new file mode 100644 index 000000000000..9fc0e50c4ee1 --- /dev/null +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -0,0 +1,777 @@ +import React, { useEffect, useCallback, useState } from "react"; +import { Divider, Button, Alert } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useWatch } from "react-hook-form"; +import { Add } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippFormCondition } from "./CippFormCondition"; +import { CippApiResults } from "./CippApiResults"; +import languageList from "/src/data/languageList.json"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippApplicationDeployDrawer = ({ + buttonText = "Add Application", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + }); + + const selectedTenants = useWatch({ + control: formControl.control, + name: "selectedTenants", + }); + + const applicationType = useWatch({ + control: formControl.control, + name: "appType", + }); + + const searchQuerySelection = useWatch({ + control: formControl.control, + name: "packageSearch", + }); + + const updateSearchSelection = useCallback( + (searchQuerySelection) => { + if (searchQuerySelection) { + formControl.setValue("packagename", searchQuerySelection.value.packagename); + formControl.setValue("applicationName", searchQuerySelection.value.applicationName); + formControl.setValue("description", searchQuerySelection.value.description); + searchQuerySelection.value.customRepo + ? formControl.setValue("customRepo", searchQuerySelection.value.customRepo) + : null; + } + }, + [formControl.setValue] + ); + + useEffect(() => { + updateSearchSelection(searchQuerySelection); + }, [updateSearchSelection, searchQuerySelection]); + + const postUrl = { + mspApp: "/api/AddMSPApp", + StoreApp: "/api/AddStoreApp", + winGetApp: "/api/AddwinGetApp", + chocolateyApp: "/api/AddChocoApp", + officeApp: "/api/AddOfficeApp", + }; + + const ChocosearchResults = ApiPostCall({ + urlFromData: true, + }); + + const winGetSearchResults = ApiPostCall({ + urlFromData: true, + }); + + const deployApplication = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["Queued Applications"], + }); + + const searchApp = (searchText, type) => { + if (type === "choco") { + ChocosearchResults.mutate({ + url: `/api/ListAppsRepository`, + data: { search: searchText }, + queryKey: `SearchApp-${searchText}-${type}`, + }); + } + + if (type === "StoreApp") { + winGetSearchResults.mutate({ + url: `/api/ListPotentialApps`, + data: { searchString: searchText, type: "WinGet" }, + queryKey: `SearchApp-${searchText}-${type}`, + }); + } + }; + + const handleSubmit = () => { + const formData = formControl.getValues(); + const formattedData = { ...formData }; + formattedData.selectedTenants = selectedTenants.map((tenant) => ({ + defaultDomainName: tenant.value, + customerId: tenant.addedFields.customerId, + })); + + deployApplication.mutate({ + url: postUrl[applicationType?.value], + data: formattedData, + relatedQueryKeys: ["Queued Applications"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + + + + {/* Tenant Selector */} + + + + + + + + + + + + + This is a community contribution and is not covered under a vendor sponsorship. + Please join our Discord community for assistance with this MSP App. + + + + + + + + + + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* For "syncro" */} + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* For "huntress" */} + + + + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* For "automate" */} + + + + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* For "cwcommand" */} + + {selectedTenants?.map((tenant, index) => ( + + + + ))} + + + {/* Assign To Options */} + + + + + + + + + + + {/* WinGet App Section */} + + + + + + + + + + ({ + value: item, + label: `${item.applicationName} - ${item.packagename}`, + })) + : [] + } + multiple={false} + formControl={formControl} + disabled={winGetSearchResults.isLoading} + isFetching={winGetSearchResults.isLoading} + /> + + + + + + + + + + + + {/* Install Options */} + + + + + {/* Assign To Options */} + + + + + + + + + + + {/* Chocolatey App Section */} + + + + + + + + + + ({ + value: item, + label: `${item.applicationName} - ${item.packagename}`, + })) + : [] + } + multiple={false} + formControl={formControl} + isFetching={ChocosearchResults.isLoading} + /> + + + + + + + + + + + + + + + + {/* Install Options */} + + + + + + + {/* Assign To Options */} + + + + + + + + + + + {/* Office App Section */} + + + + + + + + + ({ + value: tag, + label: `${language} (${tag})`, + }))} + multiple={true} + formControl={formControl} + validators={{ required: "Please select at least one language" }} + /> + + + + + + + + + + + + + + + {/* Assign To Options */} + + + + + + + + + + ); +}; diff --git a/src/pages/endpoint/applications/list/add.jsx b/src/pages/endpoint/applications/list/add.jsx deleted file mode 100644 index 1c431696526c..000000000000 --- a/src/pages/endpoint/applications/list/add.jsx +++ /dev/null @@ -1,725 +0,0 @@ -import React, { useEffect } from "react"; -import { Divider, Button, Alert } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm, useWatch } from "react-hook-form"; -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import CippFormPage from "/src/components/CippFormPages/CippFormPage"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; -import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; -import languageList from "/src/data/languageList.json"; -import { ApiPostCall } from "../../../../api/ApiCall"; -const ApplicationDeploymentForm = () => { - const formControl = useForm({ - mode: "onChange", - }); - - const selectedTenants = useWatch({ - control: formControl.control, - name: "selectedTenants", - }); - - const applicationType = useWatch({ - control: formControl.control, - name: "appType", - }); - - const searchQuerySelection = useWatch({ - control: formControl.control, - name: "packageSearch", - }); - - useEffect(() => { - //if the searchQuerySelection was succesful, fill in the fields. - if (searchQuerySelection) { - formControl.setValue("packagename", searchQuerySelection.value.packagename); - formControl.setValue("applicationName", searchQuerySelection.value.applicationName); - formControl.setValue("description", searchQuerySelection.value.description); - searchQuerySelection.value.customRepo - ? formControl.setValue("customRepo", searchQuerySelection.value.customRepo) - : null; - } - }, [searchQuerySelection]); - - const postUrl = { - mspApp: "/api/AddMSPApp", - StoreApp: "/api/AddStoreApp", - winGetApp: "/api/AddwinGetApp", - chocolateyApp: "/api/AddChocoApp", - officeApp: "/api/AddOfficeApp", - }; - - const ChocosearchResults = ApiPostCall({ - urlFromData: true, - }); - - const winGetSearchResults = ApiPostCall({ - urlFromData: true, - }); - - const searchApp = (searchText, type) => { - if (type === "choco") { - ChocosearchResults.mutate({ - url: `/api/ListAppsRepository`, - data: { search: searchText }, - queryKey: `SearchApp-${searchText}-${type}`, - }); - } - - if (type === "StoreApp") { - winGetSearchResults.mutate({ - url: `/api/ListPotentialApps`, - data: { searchString: searchText, type: "WinGet" }, - queryKey: `SearchApp-${searchText}-${type}`, - }); - } - }; - - return ( - { - const formattedData = { ...data }; - formattedData.selectedTenants = selectedTenants.map((tenant) => ({ - defaultDomainName: tenant.value, - customerId: tenant.addedFields.customerId, - })); - return formattedData; - }} - > - - - - - {/* Tenant Selector */} - - - - - - - - - - - - - This is a community contribution and is not covered under a vendor sponsorship. Please - join our Discord community for assistance with this MSP App. - - - - - - - - - - - {selectedTenants?.map((tenant, index) => ( - - - - ))} - - - {/* For "syncro" */} - - {selectedTenants?.map((tenant, index) => ( - - - - ))} - - - {/* Similar blocks for other rmmname values */} - {/* For "huntress" */} - - - - - {selectedTenants?.map((tenant, index) => ( - - - - ))} - - - {/* For "automate" */} - - - - - {selectedTenants?.map((tenant, index) => ( - - - - ))} - {selectedTenants?.map((tenant, index) => ( - - - - ))} - - - {/* For "cwcommand" */} - - {selectedTenants?.map((tenant, index) => ( - - - - ))} - - - {/* Assign To Options */} - - - - - - - - - - - {/* WinGet App Section */} - - - - - - - - - - ({ - value: item, - label: `${item.applicationName} - ${item.packagename}`, - })) - : [] - } - multiple={false} - formControl={formControl} - isFetching={winGetSearchResults.isLoading} - /> - - - - - - - - - - - - {/* Install Options */} - - - - - {/* Assign To Options */} - - - - - - - - - - - {/* Chocolatey App Section */} - - - - - - - - - - ({ - value: item, - label: `${item.applicationName} - ${item.packagename}`, - })) - : [] - } - multiple={false} - formControl={formControl} - isFetching={ChocosearchResults.isLoading} - /> - - - - - - - - - - - - - - - - {/* Install Options */} - - - - - - - {/* Assign To Options */} - - - - - - - - - - - {/* Office App Section */} - - {/* Office App Fields */} - - - - - - - - - ({ - value: tag, - label: `${language} (${tag})`, - }))} - multiple={true} - formControl={formControl} - validators={{ required: "Please select at least one language" }} - /> - - - - - - - - - - - - - - - {/* Assign To Options */} - - - - - - - ); -}; - -ApplicationDeploymentForm.getLayout = (page) => {page}; - -export default ApplicationDeploymentForm; diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index d10fe0727555..570bc48dc625 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -1,9 +1,8 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; import { GlobeAltIcon, TrashIcon, UserIcon } from "@heroicons/react/24/outline"; -import { Add, LaptopMac } from "@mui/icons-material"; -import { Button } from "@mui/material"; -import Link from "next/link"; +import { LaptopMac } from "@mui/icons-material"; +import { CippApplicationDeployDrawer } from "/src/components/CippComponents/CippApplicationDeployDrawer"; const Page = () => { const pageTitle = "Applications"; @@ -91,9 +90,7 @@ const Page = () => { simpleColumns={simpleColumns} cardButton={ <> - + } /> From 78cc1649ed11256b80402edfbbf5f156a647f885 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:04:20 +0200 Subject: [PATCH 17/34] Add new button to queue --- src/pages/endpoint/applications/queue/index.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/endpoint/applications/queue/index.js b/src/pages/endpoint/applications/queue/index.js index 5a0dd0f3dc98..40c7da79ddd2 100644 --- a/src/pages/endpoint/applications/queue/index.js +++ b/src/pages/endpoint/applications/queue/index.js @@ -6,6 +6,7 @@ import Link from "next/link"; import { ApiPostCall } from "../../../../api/ApiCall"; import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; import { TrashIcon } from "@heroicons/react/24/outline"; +import { CippApplicationDeployDrawer } from "../../../../components/CippComponents/CippApplicationDeployDrawer"; const Page = () => { const pageTitle = "Queued Applications"; @@ -44,9 +45,9 @@ const Page = () => { Run Queue now - + <> + + } /> From 6cf261d03bbc4ff17e9acfc93903827bf69a66ef Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:12:07 +0200 Subject: [PATCH 18/34] upgrade to new drawers --- .../CippAutopilotProfileDrawer.jsx | 217 ++++++++++++++++++ .../CippAutopilotStatusPageDrawer.jsx | 177 ++++++++++++++ .../autopilot/add-status-page/index.js | 124 ---------- .../endpoint/autopilot/list-profiles/add.jsx | 164 ------------- .../endpoint/autopilot/list-profiles/index.js | 9 +- .../autopilot/list-status-pages/index.js | 12 +- 6 files changed, 398 insertions(+), 305 deletions(-) create mode 100644 src/components/CippComponents/CippAutopilotProfileDrawer.jsx create mode 100644 src/components/CippComponents/CippAutopilotStatusPageDrawer.jsx delete mode 100644 src/pages/endpoint/autopilot/add-status-page/index.js delete mode 100644 src/pages/endpoint/autopilot/list-profiles/add.jsx diff --git a/src/components/CippComponents/CippAutopilotProfileDrawer.jsx b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx new file mode 100644 index 000000000000..dc23ff41f4a7 --- /dev/null +++ b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx @@ -0,0 +1,217 @@ +import React, { useState } from "react"; +import { Divider, Button } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import { AccountCircle } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import languageList from "/src/data/languageList.json"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAutopilotProfileDrawer = ({ + buttonText = "Add Profile", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + defaultValues: { + DisplayName: "", + Description: "", + DeviceNameTemplate: "", + languages: null, + CollectHash: true, + Assignto: true, + DeploymentMode: true, + HideTerms: true, + HidePrivacy: true, + HideChangeAccount: true, + NotLocalAdmin: true, + allowWhiteglove: true, + Autokeyboard: true, + }, + }); + + const createProfile = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["Autopilot Profiles"], + }); + + const handleSubmit = () => { + const formData = formControl.getValues(); + createProfile.mutate({ + url: "/api/AddAutopilotConfig", + data: formData, + relatedQueryKeys: ["Autopilot Profiles"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + {/* Tenant Selector */} + + + + + + + + + {/* Form Fields */} + + + + + + ({ + value: tag, + label: `${language} - ${geographicArea}`, // Format as "language - geographic area" for display + }))} + formControl={formControl} + multiple={false} + /> + + + + + + + + + + + {/* Switches */} + + + + + + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/CippComponents/CippAutopilotStatusPageDrawer.jsx b/src/components/CippComponents/CippAutopilotStatusPageDrawer.jsx new file mode 100644 index 000000000000..f8c254fcf198 --- /dev/null +++ b/src/components/CippComponents/CippAutopilotStatusPageDrawer.jsx @@ -0,0 +1,177 @@ +import React, { useState } from "react"; +import { Divider, Button } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm } from "react-hook-form"; +import { PostAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAutopilotStatusPageDrawer = ({ + buttonText = "Add Status Page", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm({ + mode: "onChange", + defaultValues: { + TimeOutInMinutes: "", + ErrorMessage: "", + ShowProgress: false, + EnableLog: false, + OBEEOnly: false, + blockDevice: false, + Allowretry: false, + AllowReset: false, + AllowFail: false, + }, + }); + + const createStatusPage = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["Autopilot Status Pages"], + }); + + const handleSubmit = () => { + const formData = formControl.getValues(); + createStatusPage.mutate({ + url: "/api/AddEnrollment", + data: formData, + relatedQueryKeys: ["Autopilot Status Pages"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + + + } + > + + {/* Tenant Selector */} + + + + + + + + + {/* Form Fields */} + + + + + + + + + {/* Switches */} + + + + + + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/pages/endpoint/autopilot/add-status-page/index.js b/src/pages/endpoint/autopilot/add-status-page/index.js deleted file mode 100644 index 3057f7e365d8..000000000000 --- a/src/pages/endpoint/autopilot/add-status-page/index.js +++ /dev/null @@ -1,124 +0,0 @@ -import { Divider } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import CippFormPage from "/src/components/CippFormPages/CippFormPage"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; - -const Page = () => { - const formControl = useForm({ - mode: "onChange", - defaultValues: { - TimeOutInMinutes: "", - ErrorMessage: "", - ShowProgress: false, - EnableLog: false, - OBEEOnly: false, - blockDevice: false, - Allowretry: false, - AllowReset: false, - AllowFail: false, - }, - }); - - return ( - - - {/* Tenant Selector */} - - - - - - - {/* Form Fields */} - - - - - - - - - {/* Switches */} - - - - - - - - - - - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/pages/endpoint/autopilot/list-profiles/add.jsx b/src/pages/endpoint/autopilot/list-profiles/add.jsx deleted file mode 100644 index 6f86da5c0d63..000000000000 --- a/src/pages/endpoint/autopilot/list-profiles/add.jsx +++ /dev/null @@ -1,164 +0,0 @@ -import { Divider } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import CippFormPage from "/src/components/CippFormPages/CippFormPage"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; -import languageList from "/src/data/languageList.json"; - -const AutopilotProfileForm = () => { - const formControl = useForm({ - mode: "onChange", - defaultValues: { - DisplayName: "", - Description: "", - DeviceNameTemplate: "", - languages: null, - CollectHash: true, - Assignto: true, - DeploymentMode: true, - HideTerms: true, - HidePrivacy: true, - HideChangeAccount: true, - NotLocalAdmin: true, - allowWhiteglove: true, - Autokeyboard: true, - }, - }); - - return ( - - - {/* Tenant Selector */} - - - - - - - {/* Form Fields */} - - - - - - ({ - value: tag, - label: `${language} - ${geographicArea}`, // Format as "language - geographic area" for display - }))} - formControl={formControl} - multiple={false} - /> - - - - - - - - - - - {/* Switches */} - - - - - - - - - - - - - - ); -}; - -AutopilotProfileForm.getLayout = (page) => {page}; - -export default AutopilotProfileForm; diff --git a/src/pages/endpoint/autopilot/list-profiles/index.js b/src/pages/endpoint/autopilot/list-profiles/index.js index d4ac60e2ea8a..66a0656254a2 100644 --- a/src/pages/endpoint/autopilot/list-profiles/index.js +++ b/src/pages/endpoint/autopilot/list-profiles/index.js @@ -4,6 +4,7 @@ import { Button } from "@mui/material"; import Link from "next/link"; import { AccountCircle } from "@mui/icons-material"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; +import { CippAutopilotProfileDrawer } from "/src/components/CippComponents/CippAutopilotProfileDrawer"; const Page = () => { const pageTitle = "Autopilot Profiles"; @@ -32,13 +33,7 @@ const Page = () => { simpleColumns={simpleColumns} cardButton={ <> - + } /> diff --git a/src/pages/endpoint/autopilot/list-status-pages/index.js b/src/pages/endpoint/autopilot/list-status-pages/index.js index fc1525f4cbbb..6143b8eab136 100644 --- a/src/pages/endpoint/autopilot/list-status-pages/index.js +++ b/src/pages/endpoint/autopilot/list-status-pages/index.js @@ -1,8 +1,6 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Button } from "@mui/material"; -import Link from "next/link"; -import { PostAdd } from "@mui/icons-material"; +import { CippAutopilotStatusPageDrawer } from "/src/components/CippComponents/CippAutopilotStatusPageDrawer"; const Page = () => { const pageTitle = "Autopilot Status Pages"; @@ -26,13 +24,7 @@ const Page = () => { simpleColumns={simpleColumns} cardButton={ <> - + } /> From 640fb6d255cebfebed97a829af854fe1f64cff6d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:44:43 +0200 Subject: [PATCH 19/34] global variables --- src/pages/cipp/settings/tabOptions.json | 4 ---- .../administration/tenants}/global-variables.js | 0 src/pages/tenant/administration/tenants/tabOptions.json | 4 ++++ 3 files changed, 4 insertions(+), 4 deletions(-) rename src/pages/{cipp/settings => tenant/administration/tenants}/global-variables.js (100%) diff --git a/src/pages/cipp/settings/tabOptions.json b/src/pages/cipp/settings/tabOptions.json index b68bec5eb43e..6231cad32485 100644 --- a/src/pages/cipp/settings/tabOptions.json +++ b/src/pages/cipp/settings/tabOptions.json @@ -26,9 +26,5 @@ { "label": "Licenses", "path": "/cipp/settings/licenses" - }, - { - "label": "Global Variables", - "path": "/cipp/settings/global-variables" } ] diff --git a/src/pages/cipp/settings/global-variables.js b/src/pages/tenant/administration/tenants/global-variables.js similarity index 100% rename from src/pages/cipp/settings/global-variables.js rename to src/pages/tenant/administration/tenants/global-variables.js diff --git a/src/pages/tenant/administration/tenants/tabOptions.json b/src/pages/tenant/administration/tenants/tabOptions.json index 66f0af8440a1..f36fc14e96d0 100644 --- a/src/pages/tenant/administration/tenants/tabOptions.json +++ b/src/pages/tenant/administration/tenants/tabOptions.json @@ -6,5 +6,9 @@ { "label": "Groups", "path": "/tenant/administration/tenants/groups" + }, + { + "label": "Global Variables", + "path": "/tenant/administration/tenants/global-variables" } ] From 0cc6bd9580a80a8aa261297e1c827e0a789007ca Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:31:46 +0200 Subject: [PATCH 20/34] test --- src/pages/tenant/gdap-management/invites/add.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tenant/gdap-management/invites/add.js b/src/pages/tenant/gdap-management/invites/add.js index ba659a92cffb..f7b09d1d0a59 100644 --- a/src/pages/tenant/gdap-management/invites/add.js +++ b/src/pages/tenant/gdap-management/invites/add.js @@ -45,7 +45,7 @@ const Page = () => { const templateList = ApiGetCall({ url: "/api/ExecGDAPRoleTemplate", - queryKey: "ListGDAPRoleTemplates", + queryKey: "ListGDAPRoleTemplates-list", }); const selectedTemplate = useWatch({ control: formControl.control, name: "roleMappings" }); From 6d128312299ea571a196661ff931159870e364d2 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 11 Aug 2025 21:08:42 -0400 Subject: [PATCH 21/34] include more defensive checks on gdap overview page --- src/pages/tenant/gdap-management/index.js | 47 ++++++++++++++++------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/src/pages/tenant/gdap-management/index.js b/src/pages/tenant/gdap-management/index.js index 372c3422e0b6..092a0e26f3e7 100644 --- a/src/pages/tenant/gdap-management/index.js +++ b/src/pages/tenant/gdap-management/index.js @@ -57,9 +57,12 @@ const Page = () => { if (roleTemplates.isSuccess) { var promptCreateDefaults = true; // check templates for CIPP Defaults + const firstPageResults = roleTemplates?.data?.pages?.[0]?.Results; if ( - roleTemplates?.data?.pages?.[0].Results?.length > 0 && - roleTemplates?.data?.pages?.[0].Results?.find((t) => t?.TemplateId === "CIPP Defaults") + firstPageResults && + Array.isArray(firstPageResults) && + firstPageResults.length > 0 && + firstPageResults.find((t) => t?.TemplateId === "CIPP Defaults") ) { promptCreateDefaults = false; } @@ -69,11 +72,28 @@ const Page = () => { useEffect(() => { if (mappedRoles.isSuccess && roleTemplates.isSuccess && pendingInvites.isSuccess) { - if (mappedRoles?.data?.pages?.[0]?.length > 0) { + const mappedRolesFirstPage = mappedRoles?.data?.pages?.[0]; + if ( + mappedRolesFirstPage && + Array.isArray(mappedRolesFirstPage) && + mappedRolesFirstPage.length > 0 + ) { setActiveStep(1); - if (roleTemplates?.data?.pages?.[0]?.Results?.length > 0) { + + const roleTemplatesFirstPage = roleTemplates?.data?.pages?.[0]?.Results; + if ( + roleTemplatesFirstPage && + Array.isArray(roleTemplatesFirstPage) && + roleTemplatesFirstPage.length > 0 + ) { setActiveStep(2); - if (pendingInvites?.data?.pages?.[0]?.length > 0) { + + const pendingInvitesFirstPage = pendingInvites?.data?.pages?.[0]; + if ( + pendingInvitesFirstPage && + Array.isArray(pendingInvitesFirstPage) && + pendingInvitesFirstPage.length > 0 + ) { setActiveStep(4); } } @@ -109,16 +129,17 @@ const Page = () => { icon: , data: relationships.data?.pages - ?.map((page) => page?.Results?.length) - .reduce((a, b) => a + b, 0) ?? 0, + ?.map((page) => page?.Results?.length || 0) + .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0, name: "GDAP Relationships", color: "secondary", }, { icon: , data: - mappedRoles.data?.pages?.map((page) => page?.length).reduce((a, b) => a + b, 0) ?? - 0, + mappedRoles.data?.pages + ?.map((page) => page?.length || 0) + .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0, name: "Mapped Admin Roles", color: "green", }, @@ -126,16 +147,16 @@ const Page = () => { icon: , data: roleTemplates.data?.pages - ?.map((page) => page?.Results?.length) - .reduce((a, b) => a + b, 0) ?? 0, + ?.map((page) => page?.Results?.length || 0) + .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0, name: "Role Templates", }, { icon: , data: pendingInvites.data?.pages - ?.map((page) => page?.length) - .reduce((a, b) => a + b, 0) ?? 0, + ?.map((page) => page?.length || 0) + .reduce((a, b) => (a || 0) + (b || 0), 0) ?? 0, name: "Pending Invites", }, ]} From 218e1aa6fda4d494eb06529ff3a1247acfbdfb7e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 12 Aug 2025 12:13:00 -0400 Subject: [PATCH 22/34] fix alert form, prevent errors due to missing tenant --- .../alert-configuration/alert.jsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index 44e85901284f..cb51e6235fbc 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -24,7 +24,8 @@ import CippButtonCard from "../../../../components/CippCards/CippButtonCard"; import alertList from "/src/data/alerts.json"; import auditLogTemplates from "/src/data/AuditLogTemplates"; import auditLogSchema from "/src/data/AuditLogSchema.json"; -import DeleteIcon from "@mui/icons-material/Delete"; // Icon for removing added inputs +import { Save, Delete } from "@mui/icons-material"; + import { Layout as DashboardLayout } from "/src/layouts/index.js"; // Dashboard layout import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; @@ -261,7 +262,7 @@ const AlertWizard = () => { recommendedOption.label += " (Recommended)"; } setRecurrenceOptions(updatedRecurrenceOptions); - + // Only set the recommended recurrence if we're NOT editing an existing alert if (!editAlert) { formControl.setValue("recurrence", recommendedOption); @@ -429,6 +430,11 @@ const AlertWizard = () => { allTenants={true} label="Included Tenants for alert" includeGroups={true} + required={true} + validators={{ + validate: (value) => + value?.length > 0 || "At least one tenant must be selected", + }} />
    { + } @@ -592,7 +602,7 @@ const AlertWizard = () => { color="error" onClick={() => handleRemoveCondition(event.id)} > - +
    @@ -673,7 +683,12 @@ const AlertWizard = () => { + } From 73f725f201f5079feda062f04da52f64718c62de Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 12 Aug 2025 12:15:39 -0400 Subject: [PATCH 23/34] handle null values for tenants --- src/utils/get-cipp-formatting.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 2b3a9bb7f4e7..3aff37d2a74c 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -301,10 +301,21 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr ) { //check if data is an array. if (Array.isArray(data)) { + // Filter out null/undefined values and map the valid items + const validItems = data.filter(item => item !== null && item !== undefined); + + if (validItems.length === 0) { + return isText ? ( + "No data" + ) : ( + + ); + } + return isText - ? data.join(", ") + ? validItems.map(item => item?.label !== undefined ? item.label : item).join(", ") : renderChipList( - data.map((item, key) => { + validItems.map((item, key) => { const itemText = item?.label !== undefined ? item.label : item; let icon = null; @@ -330,6 +341,15 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr }) ); } else { + // Handle null/undefined single element + if (data === null || data === undefined) { + return isText ? ( + "No data" + ) : ( + + ); + } + const itemText = data?.label !== undefined ? data.label : data; let icon = null; From e1ff41eaab6b0611c4e0b978ce9ac0863999afb2 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 12 Aug 2025 12:47:01 -0400 Subject: [PATCH 24/34] fix tenant mapping in integrations fix label on integration company clear form values on add --- .../CippIntegrations/CippIntegrationTenantMapping.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx index 23351820c1bd..fe0039c63799 100644 --- a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx @@ -93,6 +93,10 @@ const CippIntegrationSettings = ({ children }) => { }; setTableData([...tableData, newRowData]); + + // Clear the form fields after successfully adding the mapping + formControl.setValue("tenantFilter", null); + formControl.setValue("integrationCompany", null); }; const handleAutoMap = () => { @@ -187,7 +191,7 @@ const CippIntegrationSettings = ({ children }) => { fullWidth name="integrationCompany" formControl={formControl} - placeholder={`Select ${extension.name} Company`} + label={`Select ${extension.name} Company`} options={mappings?.data?.Companies?.map((company) => { return { label: company.name, From 5fa519da22e436446e91c0ed125fe1de7aa7a34a Mon Sep 17 00:00:00 2001 From: Zac Richards <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 13 Aug 2025 23:20:57 +0800 Subject: [PATCH 25/34] Fix richText editor value sync and effect dependencies Improves synchronization of the richText editor's content with external value changes in CippFormComponent by tracking the last set value and updating content accordingly. Also updates the dependency array in exchange.jsx to include oooRequest.data for more accurate effect triggering. --- .../CippComponents/CippFormComponent.jsx | 21 ++++++++++++------- .../administration/users/user/exchange.jsx | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 1e527273608f..ca794759d40d 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -345,7 +345,7 @@ export const CippFormComponent = (props) => { case "richText": { const editorInstanceRef = React.useRef(null); - const hasSetInitialValue = React.useRef(false); + const lastSetValue = React.useRef(null); return ( <> @@ -357,15 +357,15 @@ export const CippFormComponent = (props) => { render={({ field }) => { const { value, onChange, ref } = field; - // Set content only once on first render + // Update content when value changes externally React.useEffect(() => { if ( editorInstanceRef.current && - !hasSetInitialValue.current && - typeof value === "string" + typeof value === "string" && + value !== lastSetValue.current ) { editorInstanceRef.current.commands.setContent(value || "", false); - hasSetInitialValue.current = true; + lastSetValue.current = value; } }, [value]); @@ -376,12 +376,19 @@ export const CippFormComponent = (props) => { {...other} ref={ref} extensions={[StarterKit]} - content="" // do not preload content + content="" onCreate={({ editor }) => { editorInstanceRef.current = editor; + // Set initial content when editor is created + if (typeof value === "string") { + editor.commands.setContent(value || "", false); + lastSetValue.current = value; + } }} onUpdate={({ editor }) => { - onChange(editor.getHTML()); + const newValue = editor.getHTML(); + lastSetValue.current = newValue; + onChange(newValue); }} label={label} renderControls={() => ( diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 6cf9f325d18e..c23c8bdc9b10 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -266,7 +266,7 @@ const Page = () => { new Date(oooRequest.data?.EndTime).getTime() / 1000 || null ); } - }, [oooRequest.isSuccess]); + }, [oooRequest.isSuccess, oooRequest.data]); useEffect(() => { //if userId is defined, we can fetch the user data From 4a40c965eb52bf79910f48a5cdff546edc387d2a Mon Sep 17 00:00:00 2001 From: Zac Richards <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 14 Aug 2025 00:15:23 +0800 Subject: [PATCH 26/34] Fix blocked for spam --- src/pages/identity/administration/users/user/exchange.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 6cf9f325d18e..3eaaf8ee4373 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -67,7 +67,7 @@ const Page = () => { const userRequest = ApiGetCall({ url: `/api/ListUserMailboxDetails?UserId=${userId}&tenantFilter=${userSettingsDefaults.currentTenant}&userMail=${graphUserRequest.data?.[0]?.userPrincipalName}`, queryKey: `Mailbox-${userId}`, - waiting: waiting, + waiting: waiting && !!graphUserRequest.data?.[0]?.userPrincipalName, }); const usersList = ApiGetCall({ From b02bfca54b9df4b2fe7a3a6edddcab0ea2f76ff6 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 14 Aug 2025 22:07:52 -0400 Subject: [PATCH 27/34] list sharepoints site members --- src/pages/teams-share/sharepoint/index.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/pages/teams-share/sharepoint/index.js b/src/pages/teams-share/sharepoint/index.js index 38baf8abb4c2..13657bb47f82 100644 --- a/src/pages/teams-share/sharepoint/index.js +++ b/src/pages/teams-share/sharepoint/index.js @@ -10,9 +10,12 @@ import { NoAccounts, } from "@mui/icons-material"; import Link from "next/link"; +import { CippDataTable } from "/src/components/CippTable/CippDataTable"; +import { useSettings } from "/src/hooks/use-settings"; const Page = () => { const pageTitle = "SharePoint Sites"; + const tenantFilter = useSettings().currentTenant; const actions = [ { @@ -178,6 +181,24 @@ const Page = () => { const offCanvas = { extendedInfoFields: ["displayName", "description", "webUrl"], actions: actions, + children: (row) => ( + + ), + size: "lg", // Make the offcanvas extra large }; return ( From 87b524fda1ad2fa1944bff12b01099036e389fb5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 15 Aug 2025 11:09:03 -0400 Subject: [PATCH 28/34] add reserved placeholder support --- .../CippWizard/CippIntunePolicy.jsx | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/components/CippWizard/CippIntunePolicy.jsx b/src/components/CippWizard/CippIntunePolicy.jsx index 8d2197dc2b13..c94d4ef93864 100644 --- a/src/components/CippWizard/CippIntunePolicy.jsx +++ b/src/components/CippWizard/CippIntunePolicy.jsx @@ -16,6 +16,28 @@ export const CippIntunePolicy = (props) => { const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); const jsonWatch = useWatch({ control: formControl.control, name: "RAWJson" }); const selectedTenants = useWatch({ control: formControl.control, name: "tenantFilter" }); + + // do not provide inputs for reserved placeholders + const reservedPlaceholders = [ + "%serial%", + "%systemroot%", + "%systemdrive%", + "%temp%", + "%tenantid%", + "%tenantfilter%", + "%initialdomain%", + "%tenantname%", + "%partnertenantid%", + "%samappid%", + "%userprofile%", + "%username%", + "%userdomain%", + "%windir%", + "%programfiles%", + "%programfiles(x86)%", + "%programdata%", + ]; + useEffect(() => { if (CATemplates.isSuccess && watcher?.value) { const template = CATemplates.data.find((template) => template.GUID === watcher.value); @@ -99,10 +121,14 @@ export const CippIntunePolicy = (props) => { const rawJson = jsonWatch ? jsonWatch : ""; const placeholderMatches = [...rawJson.matchAll(/%(\w+)%/g)].map((m) => m[1]); const uniquePlaceholders = Array.from(new Set(placeholderMatches)); - if (uniquePlaceholders.length === 0 || selectedTenants.length === 0) { + // Filter out reserved placeholders + const filteredPlaceholders = uniquePlaceholders.filter( + (placeholder) => !reservedPlaceholders.includes(`%${placeholder.toLowerCase()}%`) + ); + if (filteredPlaceholders.length === 0 || selectedTenants.length === 0) { return null; } - return uniquePlaceholders.map((placeholder) => ( + return filteredPlaceholders.map((placeholder) => ( {selectedTenants.map((tenant, idx) => ( Date: Fri, 15 Aug 2025 17:20:57 -0400 Subject: [PATCH 29/34] null safety on exchange page --- src/pages/identity/administration/users/user/exchange.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 6cf9f325d18e..0a8d469c8882 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -139,7 +139,7 @@ const Page = () => { // Exact match on display name (group.displayName && group.displayName === userIdentifier) || // Partial match - permission identifier starts with group display name (handles timestamps) - (group.displayName && userIdentifier.startsWith(group.displayName)) + (group.displayName && userIdentifier?.startsWith(group.displayName)) ); }); @@ -918,7 +918,7 @@ const Page = () => { data: graphUserRequest.data?.[0]?.proxyAddresses?.map((address) => ({ Address: address, - Type: address.startsWith("SMTP:") ? "Primary" : "Alias", + Type: address?.startsWith("SMTP:") ? "Primary" : "Alias", })) || [], refreshFunction: () => graphUserRequest.refetch(), isFetching: graphUserRequest.isFetching, From 03e83e2f7a13ea42c0ded225dbdd41850c1a254c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 15 Aug 2025 18:34:31 -0400 Subject: [PATCH 30/34] add toggle for expanding group members --- .../identity/administration/groups/index.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/pages/identity/administration/groups/index.js b/src/pages/identity/administration/groups/index.js index 1851119434a5..453b4661d4e5 100644 --- a/src/pages/identity/administration/groups/index.js +++ b/src/pages/identity/administration/groups/index.js @@ -12,9 +12,16 @@ import { Lock, GroupSharp, } from "@mui/icons-material"; +import { Stack } from "@mui/system"; +import { useState } from "react"; const Page = () => { const pageTitle = "Groups"; + const [showMembers, setShowMembers] = useState(false); + + const handleMembersToggle = () => { + setShowMembers(!showMembers); + }; const actions = [ { //tested @@ -127,13 +134,18 @@ const Page = () => { + + - + } apiUrl="/api/ListGroups" + apiData={{ expandMembers: showMembers }} + queryKey={showMembers ? "groups-with-members" : "groups-without-members"} actions={actions} offCanvas={offCanvas} simpleColumns={[ From 4283dd43ab8665360ca9f9c8d71b287d15746007 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 18 Aug 2025 13:13:49 -0400 Subject: [PATCH 31/34] policy catalog prettification --- .../CippComponents/CippFolderNavigation.jsx | 428 ++++++++++++++++++ .../CippComponents/CippOffCanvas.jsx | 28 +- .../CippComponents/CippPolicyImportDrawer.jsx | 292 +++++++----- 3 files changed, 628 insertions(+), 120 deletions(-) create mode 100644 src/components/CippComponents/CippFolderNavigation.jsx diff --git a/src/components/CippComponents/CippFolderNavigation.jsx b/src/components/CippComponents/CippFolderNavigation.jsx new file mode 100644 index 000000000000..5904a70b06b1 --- /dev/null +++ b/src/components/CippComponents/CippFolderNavigation.jsx @@ -0,0 +1,428 @@ +import { useState, useMemo } from "react"; +import { + Box, + Typography, + List, + ListItem, + ListItemIcon, + ListItemText, + ListItemButton, + Breadcrumbs, + Link, + Stack, + TextField, + InputAdornment, + IconButton, + Chip, + Slide, + Button, +} from "@mui/material"; +import { + Folder, + InsertDriveFile, + Search, + Clear, + NavigateNext, + Home, + Visibility, + SubdirectoryArrowLeft, +} from "@mui/icons-material"; +import { alpha, styled } from "@mui/material/styles"; + +const StyledListItem = styled(ListItemButton)(({ theme }) => ({ + borderRadius: theme.shape.borderRadius, + margin: theme.spacing(0.25, 0), + padding: theme.spacing(1, 2), + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.08), + }, + "&.Mui-selected": { + backgroundColor: alpha(theme.palette.primary.main, 0.12), + "&:hover": { + backgroundColor: alpha(theme.palette.primary.main, 0.16), + }, + }, +})); + +const FileListItem = styled(Box)(({ theme }) => ({ + padding: theme.spacing(1, 2), + border: `1px solid ${theme.palette.divider}`, +})); + +const NavigationContainer = styled(Box)(({ theme }) => ({ + position: "relative", + overflow: "hidden", + height: "100%", + minHeight: 400, + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + display: "flex", + flexDirection: "column", +})); + +const SlideView = styled(Box)(({ theme }) => ({ + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: theme.palette.background.paper, + display: "flex", + flexDirection: "column", +})); + +export const CippFolderNavigation = ({ + data = [], + onFileSelect, + selectedFile = null, + searchable = true, + showFileInfo = true, + onImportFile, + onViewFile, + isImporting = false, +}) => { + const [currentPath, setCurrentPath] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [slideDirection, setSlideDirection] = useState("left"); + + // Build folder structure from flat file list + const folderStructure = useMemo(() => { + const structure = { folders: {}, files: [] }; + + data.forEach((file) => { + const pathParts = file.path.split("/"); + let current = structure; + + // Build folder hierarchy + for (let i = 0; i < pathParts.length - 1; i++) { + const folderName = pathParts[i]; + if (!current.folders[folderName]) { + current.folders[folderName] = { + folders: {}, + files: [], + name: folderName, + path: pathParts.slice(0, i + 1).join("/"), + }; + } + current = current.folders[folderName]; + } + + // Add file to the final folder + current.files.push({ + ...file, + name: pathParts[pathParts.length - 1], + }); + }); + + return structure; + }, [data]); + + // Get current folder based on currentPath + const getCurrentFolder = () => { + let current = folderStructure; + for (const pathPart of currentPath) { + current = current.folders[pathPart]; + if (!current) break; + } + return current || { folders: {}, files: [] }; + }; + + // Filter files based on search term (only when searching) + const getFilteredContent = () => { + if (!searchTerm) { + return getCurrentFolder(); + } + + // When searching, show all matching files across all folders + const allFiles = data.filter((file) => + file.path.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return { + folders: {}, + files: allFiles.map((file) => ({ + ...file, + name: file.path.split("/").pop(), + })), + }; + }; + + const currentFolder = getFilteredContent(); + + const navigateToFolder = (folderName) => { + setSlideDirection("left"); + setCurrentPath((prev) => [...prev, folderName]); + }; + + const navigateBack = () => { + if (currentPath.length > 0) { + setSlideDirection("right"); + setCurrentPath((prev) => prev.slice(0, -1)); + } + }; + + const navigateTo = (index) => { + if (index < currentPath.length) { + const direction = index < currentPath.length - 1 ? "right" : "left"; + setSlideDirection(direction); + setCurrentPath((prev) => prev.slice(0, index + 1)); + } else if (index === -1) { + setSlideDirection("right"); + setCurrentPath([]); + } + }; + + const handleFileClick = (file) => { + if (onFileSelect) { + onFileSelect(file); + } + }; + + const formatFileSize = (bytes) => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + }; + + const getFileIcon = (fileName) => { + return ; + }; + + const clearSearch = () => { + setSearchTerm(""); + }; + + const folders = Object.values(currentFolder.folders || {}); + const files = currentFolder.files || []; + + return ( + + {searchable && ( + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm && ( + + + + + + ), + }} + /> + + )} + + + + + {/* Header with navigation */} + + {searchTerm ? ( + Search Results ({files.length}) + ) : ( + } + sx={{ fontSize: "0.875rem" }} + > + navigateTo(-1)} + sx={{ + display: "flex", + alignItems: "center", + textDecoration: "none", + "&:hover": { textDecoration: "underline" }, + }} + > + + + {currentPath.map((folder, index) => ( + navigateTo(index)} + sx={{ + textDecoration: "none", + "&:hover": { textDecoration: "underline" }, + }} + > + {folder} + + ))} + + )} + + + {/* Content */} + + + {/* Show ".." folder for navigation back when not at root and not searching */} + {!searchTerm && currentPath.length > 0 && ( + + + + + + + + Parent folder + + + } + /> + + + )} + + {/* Show folders first (only when not searching) */} + {!searchTerm && + folders.map((folder) => ( + navigateToFolder(folder.name)}> + + + + + {folder.name} + {folder.files.length > 0 && ( + + )} + + } + /> + + + ))} + + {/* Show files */} + {files.map((file) => ( + + + {/* File Icon and Info */} + handleFileClick(file)} + > + + {getFileIcon(file.name)} + + + + {file.name} + + + {searchTerm && ( + <> + + {file.path.substring(0, file.path.lastIndexOf("/")) || "root"} + + + • + + + )} + {showFileInfo && ( + + {formatFileSize(file.size)} + + )} + + + + + {/* Action Buttons */} + + + + + + + ))} + + {/* Empty state */} + {folders.length === 0 && files.length === 0 && ( + + + {searchTerm + ? `No files found matching "${searchTerm}"` + : "This folder is empty"} + + + )} + + + + + + + {!searchTerm && data.length === 0 && ( + + + No files available + + + )} + + ); +}; diff --git a/src/components/CippComponents/CippOffCanvas.jsx b/src/components/CippComponents/CippOffCanvas.jsx index b39c2192e1ea..25b05ed69a28 100644 --- a/src/components/CippComponents/CippOffCanvas.jsx +++ b/src/components/CippComponents/CippOffCanvas.jsx @@ -89,25 +89,26 @@ export const CippOffCanvas = (props) => { - {/* Main content area */} - - - {extendedInfo.length > 0 && ( + + {extendedInfo.length > 0 && ( + { actionItems={actions} data={extendedData} /> - )} - - - + + )} + + {/* Render children if provided, otherwise render default content */} {typeof children === "function" ? children(extendedData) : children} @@ -134,6 +140,8 @@ export const CippOffCanvas = (props) => { borderTop: 1, borderColor: "divider", p: 2, + flexShrink: 0, + mt: "auto", }} > {footer} diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx index 8f971a102260..d66fb4ac6f92 100644 --- a/src/components/CippComponents/CippPolicyImportDrawer.jsx +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -19,6 +19,7 @@ import CippFormComponent from "./CippFormComponent"; import CippJsonView from "../CippFormPages/CippJSONView"; import { CippApiResults } from "./CippApiResults"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippFolderNavigation } from "./CippFolderNavigation"; export const CippPolicyImportDrawer = ({ buttonText = "Browse Catalog", @@ -30,6 +31,7 @@ export const CippPolicyImportDrawer = ({ const [searchQuery, setSearchQuery] = useState(""); const [viewDialogOpen, setViewDialogOpen] = useState(false); const [viewingPolicy, setViewingPolicy] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); const formControl = useForm(); const selectedSource = useWatch({ control: formControl.control, name: "policySource" }); @@ -54,6 +56,15 @@ export const CippPolicyImportDrawer = ({ selectedSource?.value || "" }&Branch=main`, queryKey: `RepoPolicies-${mode}-${selectedSource?.value || "none"}`, + enabled: !!(selectedSource?.value && selectedSource?.value !== "tenant"), + }); + + const repositoryFiles = ApiGetCall({ + url: `/api/ExecGitHubAction?Action=GetFileTree&FullName=${ + selectedSource?.value || "" + }&Branch=main`, + queryKey: `RepositoryFiles-${selectedSource?.value || "none"}`, + enabled: !!(selectedSource?.value && selectedSource?.value !== "tenant"), }); const importPolicy = ApiPostCall({ @@ -167,9 +178,14 @@ export const CippPolicyImportDrawer = ({ setDrawerVisible(false); setSearchQuery(""); setViewingPolicy(null); + setSelectedFile(null); // Don't reset form at all to avoid any potential issues }; + const handleFileSelect = (file) => { + setSelectedFile(file); + }; + const handleCloseViewDialog = () => { setViewDialogOpen(false); setViewingPolicy(null); @@ -264,121 +280,177 @@ export const CippPolicyImportDrawer = ({ } > - - ({ - label: `${repo?.Name || "Unknown"} (${repo?.URL || "Unknown"})`, - value: repo?.FullName || "", - })).filter((option) => option.value) - : []), - { label: "Get template from existing tenant", value: "tenant" }, - ]} - /> - - {selectedSource?.value === "tenant" && ( - + + ({ + label: `${repo?.Name || "Unknown"} (${repo?.URL || "Unknown"})`, + value: repo?.FullName || "", + })).filter((option) => option.value) + : []), + { label: "Get template from existing tenant", value: "tenant" }, + ]} /> - )} - setSearchQuery(e.target.value)} - InputProps={{ - startAdornment: , + {selectedSource?.value === "tenant" && ( + + + + )} + + + {/* Content based on source */} + - - Available Policies ({filteredPolicies.length}) - - {/* Loading skeletons */} - {(selectedSource?.value === "tenant" && tenantPolicies.isLoading) || - (selectedSource?.value && - selectedSource?.value !== "tenant" && - repoPolicies.isLoading) ? ( - <> - {[...Array(3)].map((_, index) => ( - - - - - - - + > + {selectedSource?.value === "tenant" ? ( + // Tenant policies - show traditional list + <> + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: , + }} + placeholder="Search by policy name or description..." + /> + + Available Policies ({filteredPolicies.length}) + + {tenantPolicies.isLoading ? ( + <> + {[...Array(3)].map((_, index) => ( + + + + + + + + + ))} + + ) : Array.isArray(filteredPolicies) && filteredPolicies.length > 0 ? ( + filteredPolicies.map((policy, index) => { + if (!policy) return null; + return ( + + + + + + + {formatPolicyName(policy)} + + {policy?.description && ( + + {policy.description} + + )} + + + + ); + }) + ) : ( + + No policies available. + + )} + + + + ) : selectedSource?.value ? ( + // Repository source - show iOS-style folder navigation + <> + + Browse Repository Files + + {repositoryFiles.isLoading ? ( + + + + ) : repositoryFiles.isSuccess ? ( + + + + ) : ( + + Unable to load repository files. + + )} + + + - ))} - - ) : Array.isArray(filteredPolicies) && filteredPolicies.length > 0 ? ( - filteredPolicies.map((policy, index) => { - if (!policy) return null; - return ( - - - - - - - {formatPolicyName(policy)} - - {policy?.description && ( - - {policy.description} - - )} - - - - ); - }) - ) : ( - - No policies available. - - )} - - {/* Error handling */} - {selectedSource?.value === "tenant" && ( - - )} - {selectedSource?.value && selectedSource?.value !== "tenant" && ( - - )} - - - + + ) : ( + + Please select a policy source to continue. + + )} + + + + + + From 7055523eefe326ddd101221f6846d64416c13349 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 18 Aug 2025 13:19:48 -0400 Subject: [PATCH 32/34] prettier skeleton --- .../CippComponents/CippPolicyImportDrawer.jsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx index d66fb4ac6f92..8ffcac45400c 100644 --- a/src/components/CippComponents/CippPolicyImportDrawer.jsx +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -406,8 +406,25 @@ export const CippPolicyImportDrawer = ({ Browse Repository Files {repositoryFiles.isLoading ? ( - - + + {/* Navigation skeleton */} + + + + + {/* File/folder list skeleton */} + + {[...Array(5)].map((_, index) => ( + + + + + + + + + ))} + ) : repositoryFiles.isSuccess ? ( Date: Mon, 18 Aug 2025 15:34:38 -0400 Subject: [PATCH 33/34] Update CippPolicyImportDrawer.jsx --- src/components/CippComponents/CippPolicyImportDrawer.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx index 8ffcac45400c..be1435efaeb2 100644 --- a/src/components/CippComponents/CippPolicyImportDrawer.jsx +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -411,14 +411,18 @@ export const CippPolicyImportDrawer = ({ - + {/* File/folder list skeleton */} {[...Array(5)].map((_, index) => ( - + From 14dab3f6fb555a46cf8267f75ad52a42bc9c2ce8 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 18 Aug 2025 15:35:10 -0400 Subject: [PATCH 34/34] Update version.json --- public/version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/version.json b/public/version.json index c46c1c3a30a4..dc8317517d9b 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "8.3.0" -} + "version": "8.3.1" +} \ No newline at end of file