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 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. 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} + + + + {deployApplication.isLoading + ? "Deploying..." + : deployApplication.isSuccess + ? "Deploy Another" + : "Deploy Application"} + + + Close + + + } + > + + + + + {/* 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 */} + + + + + + { + searchApp(formControl.getValues("searchQuery"), "StoreApp"); + }} + > + Search + + + + + ({ + 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 */} + + + + + + { + searchApp(formControl.getValues("searchQuery"), "choco"); + }} + > + Search + + + + + ({ + 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/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} + + + + {createProfile.isLoading + ? "Creating..." + : createProfile.isSuccess + ? "Create Another" + : "Create Profile"} + + + Close + + + } + > + + {/* 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} + + + + {createStatusPage.isLoading + ? "Creating..." + : createStatusPage.isSuccess + ? "Create Another" + : "Create Status Page"} + + + Close + + + } + > + + {/* Tenant Selector */} + + + + + + + + + {/* Form Fields */} + + + + + + + + + {/* Switches */} + + + + + + + + + + + + + + > + ); +}; \ No newline at end of file diff --git a/src/components/CippComponents/CippCADeployDrawer.jsx b/src/components/CippComponents/CippCADeployDrawer.jsx new file mode 100644 index 000000000000..85d059d914dd --- /dev/null +++ b/src/components/CippComponents/CippCADeployDrawer.jsx @@ -0,0 +1,177 @@ +import { useEffect, useState, useCallback } 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 { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import CippFormComponent from "./CippFormComponent"; +import CippJsonView from "../CippFormPages/CippJSONView"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; + +export const CippCADeployDrawer = ({ + buttonText = "Deploy CA Policy", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const formControl = useForm(); + const tenantFilter = useSettings()?.tenantFilter; + const CATemplates = ApiGetCall({ url: "/api/ListCATemplates", queryKey: "CATemplates" }); + const [JSONData, setJSONData] = useState(); + const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); + + const updateTemplate = useCallback( + (templateGuid) => { + if (CATemplates.isSuccess && templateGuid) { + const template = CATemplates.data.find((template) => template.GUID === templateGuid); + if (template) { + setJSONData(template); + formControl.setValue("rawjson", JSON.stringify(template, null)); + } + } + }, + [CATemplates.isSuccess, CATemplates.data, formControl.setValue] + ); + + useEffect(() => { + updateTemplate(watcher?.value); + }, [updateTemplate, watcher?.value]); + + const deployPolicy = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["CATemplates", `Conditional Access Policies - ${tenantFilter}`], + }); + + const handleSubmit = () => { + const formData = formControl.getValues(); + console.log("Submitting CA form data:", formData); + deployPolicy.mutate({ + url: "/api/AddCAPolicy", + relatedQueryKeys: ["CATemplates", "Conditional Access Policies"], + data: { ...formData }, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset(); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + {deployPolicy.isLoading + ? "Deploying..." + : deployPolicy.isSuccess + ? "Redeploy Policy" + : "Deploy Policy"} + + + Close + + + } + > + + + + ({ + label: template.displayName, + value: template.GUID, + })) + : [] + } + /> + + + + + + + + + + + + + + + + + > + ); +}; 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 */} + + } + onClick={(e) => { + e.stopPropagation(); + onViewFile?.(file); + }} + sx={{ minWidth: 100 }} + > + View + + { + e.stopPropagation(); + onImportFile?.(file); + }} + disabled={isImporting} + sx={{ minWidth: 80 }} + > + Import + + + + + ))} + + {/* 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/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 7c6f7ccb6050..ca794759d40d 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} + + )} + + { case "richText": { const editorInstanceRef = React.useRef(null); - const hasSetInitialValue = React.useRef(false); + const lastSetValue = React.useRef(null); return ( <> @@ -347,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]); @@ -366,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/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; } 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..25b05ed69a28 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,73 @@ export const CippOffCanvas = (props) => { open={visible} onClose={onClose} > - - - - {/* Force vertical stacking in a column layout */} + {title} + + + + + - - + + {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} + + + + {deployPolicy.isLoading + ? "Deploying..." + : deployPolicy.isSuccess + ? "Redeploy Policy" + : "Deploy Policy"} + + + Close + + + } + > + + + ({ + 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..be1435efaeb2 --- /dev/null +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -0,0 +1,500 @@ +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"; +import { CippFolderNavigation } from "./CippFolderNavigation"; + +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 [selectedFile, setSelectedFile] = 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"}`, + 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({ + 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); + setSelectedFile(null); + // Don't reset form at all to avoid any potential issues + }; + + const handleFileSelect = (file) => { + setSelectedFile(file); + }; + + 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} + + + + Close + + + } + > + + + ({ + 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" && ( + + + + )} + + + {/* Content based on source */} + + {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 ( + + + handleImportPolicy(policy)} + disabled={importPolicy.isLoading} + sx={{ minWidth: 80, flexShrink: 0 }} + > + Import + + } + onClick={() => handleViewPolicy(policy)} + sx={{ minWidth: 120, flexShrink: 0 }} + > + View Policy + + + + {formatPolicyName(policy)} + + {policy?.description && ( + + {policy.description} + + )} + + + + ); + }) + ) : ( + + No policies available. + + )} + + + > + ) : selectedSource?.value ? ( + // Repository source - show iOS-style folder navigation + <> + + Browse Repository Files + + {repositoryFiles.isLoading ? ( + + {/* Navigation skeleton */} + + + + + {/* File/folder list skeleton */} + + {[...Array(5)].map((_, index) => ( + + + + + + + + + ))} + + + ) : repositoryFiles.isSuccess ? ( + + + + ) : ( + + Unable to load repository files. + + )} + + + + + > + ) : ( + + Please select a policy source to continue. + + )} + + + + + + + + + + Policy Details + + {viewPolicyQuery.isPending ? ( + + + + ) : ( + + )} + + + + Close + + + + > + ); +}; 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/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, 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/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) => ( { 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 ( - - } - disabled={true} - sx={{ - fontWeight: "bold", - textTransform: "none", - borderRadius: 2, - boxShadow: "0 2px 8px rgba(0,0,0,0.15)", - transition: "all 0.2s ease-in-out", - }} - {...other} - > - Loading Data... - - - ); - } - return ( <> - {/* Main Executive Summary Button */} + {/* Main Executive Summary Button - Always available */} { {/* Combined Preview and Configuration Dialog */} setPreviewOpen(false)} + onClose={handleClose} maxWidth="xl" fullWidth sx={{ @@ -2193,7 +2202,7 @@ export const ExecutiveReportButton = (props) => { Executive Report - {tenantName} - setPreviewOpen(false)} size="small"> + @@ -2303,17 +2312,47 @@ export const ExecutiveReportButton = (props) => { {/* Right Panel - PDF Preview */} - - {reportDocument} - + {isDataLoading ? ( + + Loading Report Data... + + Fetching additional data for comprehensive report generation + + + ) : reportDocument ? ( + + {reportDocument} + + ) : ( + + + Report preview will appear here + + + )} @@ -2328,7 +2367,7 @@ export const ExecutiveReportButton = (props) => { } - disabled={!shouldShowButton} + disabled={isDataLoading} sx={{ minWidth: 140 }} onClick={() => { // Create document dynamically when download is clicked @@ -2373,10 +2412,10 @@ export const ExecutiveReportButton = (props) => { }); }} > - Download PDF + {isDataLoading ? "Loading..." : "Download PDF"} - setPreviewOpen(false)} variant="outlined"> + Close 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(() => { 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/cipp/settings/backup.js b/src/pages/cipp/settings/backup.js index 1931dca8f87a..4c5249cf51ec 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,25 @@ 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"; +import { useDialog } from "../../../hooks/use-dialog"; const Page = () => { + const [validationResult, setValidationResult] = useState(null); + const restoreDialog = useDialog(); + const [selectedBackupFile, setSelectedBackupFile] = useState(null); + const [selectedBackupData, setSelectedBackupData] = useState(null); + const backupList = ApiGetCall({ url: "/api/ExecListBackup", data: { @@ -53,6 +78,80 @@ const Page = () => { relatedQueryKeys: ["ScheduledBackup"], }); + // Component for displaying validation results + const ValidationResultsDisplay = ({ result }) => { + if (!result) return null; + + return ( + + {result.isValid ? ( + }> + Backup Validation Successful + + The backup file is valid and ready for restoration. + + {result.validRows !== undefined && result.totalRows !== undefined && ( + + Import Summary: {result.validRows} valid rows out of{" "} + {result.totalRows} total rows will be imported. + + )} + {result.repaired && ( + + Note: The backup file had minor issues that were automatically + repaired. + + )} + {result.warnings.length > 0 && ( + + + Warnings: + + + {result.warnings.map((warning, index) => ( + + + {warning} + + + ))} + + + )} + + ) : ( + }> + Backup Validation Failed + + The backup file is corrupted and cannot be restored safely. + + {result.validRows !== undefined && result.totalRows !== undefined && ( + + Analysis: Found {result.validRows} valid rows out of{" "} + {result.totalRows} total rows. + + )} + + + Errors found: + + + {result.errors.map((error, index) => ( + + {error} + + ))} + + + + Please try downloading a fresh backup or contact support if this issue persists. + + + )} + + ); + }; + const NextBackupRun = (props) => { const date = new Date(props.date); if (isNaN(date)) { @@ -80,20 +179,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 + restoreDialog.handleOpen(); + + // Clear the file input + e.target.value = null; + } catch (error) { + console.error("Backup validation error:", error); + setValidationResult({ + isValid: false, + errors: [`Validation failed: ${error.message}`], + warnings: [], + repaired: false, + }); + setSelectedBackupFile({ + name: file.name, + size: file.size, + lastModified: new Date(file.lastModified), + }); + setSelectedBackupData(null); + restoreDialog.handleOpen(); + e.target.value = null; + } }; reader.readAsText(file); }; @@ -110,11 +241,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 +356,7 @@ const Page = () => { + { )} + + {/* Backup Restore Confirmation Dialog */} + { + restoreDialog.handleClose(); + // Clear state when user manually closes the dialog + setValidationResult(null); + setSelectedBackupFile(null); + setSelectedBackupData(null); + }, + }} + api={{ + type: "POST", + url: "/api/ExecRestoreBackup", + customDataformatter: () => selectedBackupData, + confirmText: validationResult?.isValid + ? "Are you sure you want to restore this backup? This will overwrite your current CIPP configuration." + : null, + onSuccess: () => { + // Don't auto-close the dialog - let user see the results and close manually + // The dialog will show the API results and user can close when ready + }, + }} + 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/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/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/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 */} - - - - - - { - searchApp(formControl.getValues("searchQuery"), "StoreApp"); - }} - > - Search - - - - - ({ - value: item, - label: `${item.applicationName} - ${item.packagename}`, - })) - : [] - } - multiple={false} - formControl={formControl} - isFetching={winGetSearchResults.isLoading} - /> - - - - - - - - - - - - {/* Install Options */} - - - - - {/* Assign To Options */} - - - - - - - - - - - {/* Chocolatey App Section */} - - - - - - { - searchApp(formControl.getValues("searchQuery"), "choco"); - }} - > - Search - - - - - ({ - 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={ <> - }> - Add Application - + > } /> 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 - }> - Add Application - + <> + + > > } /> 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={ <> - } - > - Add Profile - + > } /> 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={ <> - } - > - Add Status Page - + > } /> 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 = () => { + + + {showMembers ? "Hide Members" : "Show Members"} + }> Add Group - > + } apiUrl="/api/ListGroups" + apiData={{ expandMembers: showMembers }} + queryKey={showMembers ? "groups-with-members" : "groups-without-members"} actions={actions} offCanvas={offCanvas} simpleColumns={[ diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index d4f6014ecb4d..22f77d21e093 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({ @@ -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)) ); }); @@ -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 @@ -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, @@ -912,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, 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 = () => { { 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 ( diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index fc3950a47a4e..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"; @@ -132,6 +133,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 +147,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 @@ -253,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); @@ -332,6 +341,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, }; @@ -420,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", + }} /> { + } + > Save Alert } @@ -583,7 +602,7 @@ const AlertWizard = () => { color="error" onClick={() => handleRemoveCondition(event.id)} > - + @@ -664,7 +683,12 @@ const AlertWizard = () => { + } + > Save Alert } @@ -700,6 +724,15 @@ const AlertWizard = () => { options={recurrenceOptions} // Use the state-managed recurrenceOptions here /> + + + {commandValue?.value?.requiresInput && ( { - 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; 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 = () => { - } - > - Deploy Conditional Access Policy - + > } title={pageTitle} 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 = () => { + <> + + + > } /> ); 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", }, ]} 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" }); 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", 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} - - - ))} - - diff --git a/src/utils/backupValidation.js b/src/utils/backupValidation.js new file mode 100644 index 000000000000..fe3880bd2558 --- /dev/null +++ b/src/utils/backupValidation.js @@ -0,0 +1,384 @@ +/** + * 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, + validRows: 0, + totalRows: 0, + }; + + try { + // Step 1: Basic checks + if (!jsonString || jsonString.trim().length === 0) { + result.errors.push("Backup file is empty"); + return result; + } + + // Step 2: Try to parse JSON directly first + let parsedData; + try { + parsedData = JSON.parse(jsonString); + } catch (parseError) { + result.warnings.push(`Initial JSON parsing failed: ${parseError.message}`); + + // 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(...repairResult.errors); + return result; + } + } + + // 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}`); + } + + return result; + }, + + /** + * Attempts basic repair of common JSON issues + */ + _attemptBasicRepair(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: 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; + + if (openBraces - closeBraces > 0 && openBraces - closeBraces < 3) { + repaired += "}".repeat(openBraces - closeBraces); + } + if (openBrackets - closeBrackets > 0 && openBrackets - closeBrackets < 3) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + + // Test if repair worked + try { + JSON.parse(repaired); + result.success = true; + result.repairedJson = repaired; + } catch (parseError) { + // 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}`); + } + + return result; + }, + + /** + * Advanced repair for severely corrupted entries + * Attempts to isolate and fix/remove corrupted entries that break the entire JSON + */ + _attemptAdvancedRepair(jsonString, parseError) { + const result = { success: false, repairedJson: jsonString, errors: [] }; + + try { + // 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); + } + + // 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}`); + } + + return result; + }, + + /** + * Attempts to isolate and remove/fix a corrupted entry + */ + _isolateCorruptedEntry(jsonString, errorPosition) { + const result = { success: false, repairedJson: jsonString, errors: [] }; + + try { + // Find the object that contains the corruption + const beforeError = jsonString.substring(0, errorPosition); + const afterError = jsonString.substring(errorPosition); + + // Look for the last complete object boundary before the error + const lastObjectStart = beforeError.lastIndexOf('{\n "PartitionKey"'); + const nextObjectStart = afterError.indexOf('\n },\n {\n "PartitionKey"'); + + if (lastObjectStart !== -1 && nextObjectStart !== -1) { + const beforeCorrupted = jsonString.substring(0, lastObjectStart); + const afterCorrupted = jsonString.substring(errorPosition + nextObjectStart); + + // Try to reconstruct without the corrupted entry + let repaired = beforeCorrupted + afterCorrupted; + + // Clean up any resulting syntax issues + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + repaired = repaired.replace(/\{\s*,/g, "{"); + repaired = repaired.replace(/,\s*,/g, ","); + + try { + 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) { + result.errors.push(`Corruption isolation failed: ${error.message}`); + } + + return result; + }, + + /** + * Extracts valid entries up to the point of corruption + */ + _extractValidEntries(jsonString, parseError) { + const result = { success: false, repairedJson: jsonString, errors: [] }; + + try { + const positionMatch = parseError.message.match(/position (\d+)/); + if (!positionMatch) { + result.errors.push("Cannot determine corruption position"); + return result; + } + + const errorPosition = parseInt(positionMatch[1]); + const beforeError = jsonString.substring(0, errorPosition); + + // Find the last complete object before the error + const lastCompleteObject = beforeError.lastIndexOf("\n }"); + + if (lastCompleteObject !== -1) { + // Extract everything up to the last complete object + let validPortion = jsonString.substring(0, lastCompleteObject + 6); // Include the \n } + + // Ensure proper JSON array closure + if (!validPortion.trim().endsWith("]")) { + validPortion += "\n]"; + } + + 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}`); + } + } + } catch (error) { + result.errors.push(`Entry extraction failed: ${error.message}`); + } + + return result; + }, + + /** + * Validates that we have importable data rows + * Filters out corrupted entries but keeps valid ones + */ + _validateImportableData(data) { + const result = { + cleanData: null, + validRows: 0, + totalRows: 0, + skippedRows: 0, + warnings: [], + }; + + // Handle non-array data + if (!Array.isArray(data)) { + 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; + } + } + + result.totalRows = data.length; + const cleanRows = []; + + // 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)}`); + } + }); + + result.cleanData = cleanRows; + return result; + }, + + /** + * Checks if a row is valid for import into CIPP tables + */ + _isValidImportRow(row) { + // Must be an object + if (!row || typeof row !== "object") { + 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"; + + // All three are required for valid CIPP backup row + if (!hasTable || !hasPartitionKey || !hasRowKey) { + return false; + } + + // 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; + } + + // Skip rows with null bytes (always corruption) + if (rowJson.includes("\0")) { + return false; + } + + return true; + }, + + /** + * Gets a human-readable reason why a row was skipped + */ + _getRowSkipReason(row) { + if (!row || typeof row !== "object") { + return "Not a valid object"; + } + + // 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); + if (rowJson.length > 10000000) { + return "Row too large (likely corrupted)"; + } + + if (rowJson.includes("\0")) { + return "Contains null bytes (corrupted)"; + } + + return "Unknown validation failure"; + }, +}; + +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; +} 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;
This is a placeholder page for the deploy ca policies section.