diff --git a/public/languageList.json b/public/languageList.json index 3bd0ec3d8e82..1c5e29b48a5f 100644 --- a/public/languageList.json +++ b/public/languageList.json @@ -5,6 +5,96 @@ "tag": "ar-SA", "LCID": "1025" }, + { + "language": "Arabic", + "Geographic area": "Algeria", + "tag": "ar-DZ", + "LCID": "5121" + }, + { + "language": "Arabic", + "Geographic area": "Egypt", + "tag": "ar-EG", + "LCID": "3073" + }, + { + "language": "Arabic", + "Geographic area": "Bahrain", + "tag": "ar-BH", + "LCID": "15361" + }, + { + "language": "Arabic", + "Geographic area": "Iraq", + "tag": "ar-IQ", + "LCID": "2049" + }, + { + "language": "Arabic", + "Geographic area": "Jordan", + "tag": "ar-JO", + "LCID": "11265" + }, + { + "language": "Arabic", + "Geographic area": "Kuwait", + "tag": "ar-KW", + "LCID": "13313" + }, + { + "language": "Arabic", + "Geographic area": "Lebanon", + "tag": "ar-LB", + "LCID": "12289" + }, + { + "language": "Arabic", + "Geographic area": "Libya", + "tag": "ar-LY", + "LCID": "4097" + }, + { + "language": "Arabic", + "Geographic area": "Morocco", + "tag": "ar-MA", + "LCID": "6145" + }, + { + "language": "Arabic", + "Geographic area": "Oman", + "tag": "ar-OM", + "LCID": "8193" + }, + { + "language": "Arabic", + "Geographic area": "Qatar", + "tag": "ar-QA", + "LCID": "16385" + }, + { + "language": "Arabic", + "Geographic area": "Syria", + "tag": "ar-SY", + "LCID": "10241" + }, + { + "language": "Arabic", + "Geographic area": "Tunisia", + "tag": "ar-TN", + "LCID": "7169" + }, + { + "language": "Arabic", + "Geographic area": "UAE", + "tag": "ar-AE", + "LCID": "14337" + }, + { + "language": "Arabic", + "Geographic area": "Yemen", + "tag": "ar-YE", + "LCID": "9217" + }, { "language": "Bulgarian", "Geographic area": "Bulgaria", @@ -23,6 +113,12 @@ "tag": "zh-TW", "LCID": "1028" }, + { + "language": "Chinese", + "Geographic area": "Hong Kong SAR", + "tag": "zh-HK", + "LCID": "3076" + }, { "language": "Croatian", "Geographic area": "Croatia", @@ -53,6 +149,42 @@ "tag": "en-US", "LCID": "1033" }, + { + "language": "English", + "Geographic area": "Australia", + "tag": "en-AU", + "LCID": "3081" + }, + { + "language": "English", + "Geographic area": "United Kingdom", + "tag": "en-GB", + "LCID": "2057" + }, + { + "language": "English", + "Geographic area": "New Zealand", + "tag": "en-NZ", + "LCID": "5129" + }, + { + "language": "English", + "Geographic area": "Canada", + "tag": "en-CA", + "LCID": "4105" + }, + { + "language": "English", + "Geographic area": "South Africa", + "tag": "en-ZA", + "LCID": "7177" + }, + { + "language": "English", + "Geographic area": "Singapore", + "tag": "en-SG", + "LCID": "4100" + }, { "language": "Estonian", "Geographic area": "Estonia", @@ -71,12 +203,30 @@ "tag": "fr-FR", "LCID": "1036" }, + { + "language": "French", + "Geographic area": "Canada", + "tag": "fr-CA", + "LCID": "3084" + }, + { + "language": "French", + "Geographic area": "Switzerland", + "tag": "fr-CH", + "LCID": "4108" + }, { "language": "German", "Geographic area": "Germany", "tag": "de-DE", "LCID": "1031" }, + { + "language": "German", + "Geographic area": "Switzerland", + "tag": "de-CH", + "LCID": "2055" + }, { "language": "Greek", "Geographic area": "Greece", @@ -155,6 +305,12 @@ "tag": "nb-NO", "LCID": "1044" }, + { + "language": "Persian", + "Geographic area": "Iran", + "tag": "fa-IR", + "LCID": "1065" + }, { "language": "Polish", "Geographic area": "Poland", @@ -209,6 +365,108 @@ "tag": "es-ES", "LCID": "3082" }, + { + "language": "Spanish", + "Geographic area": "Argentina", + "tag": "es-AR", + "LCID": "11274" + }, + { + "language": "Spanish", + "Geographic area": "Bolivia", + "tag": "es-BO", + "LCID": "16394" + }, + { + "language": "Spanish", + "Geographic area": "Chile", + "tag": "es-CL", + "LCID": "13322" + }, + { + "language": "Spanish", + "Geographic area": "Colombia", + "tag": "es-CO", + "LCID": "9226" + }, + { + "language": "Spanish", + "Geographic area": "Costa Rica", + "tag": "es-CR", + "LCID": "5130" + }, + { + "language": "Spanish", + "Geographic area": "Dominican Republic", + "tag": "es-DO", + "LCID": "7178" + }, + { + "language": "Spanish", + "Geographic area": "Ecuador", + "tag": "es-EC", + "LCID": "12298" + }, + { + "language": "Spanish", + "Geographic area": "El Salvador", + "tag": "es-SV", + "LCID": "17418" + }, + { + "language": "Spanish", + "Geographic area": "Guatemala", + "tag": "es-GT", + "LCID": "4106" + }, + { + "language": "Spanish", + "Geographic area": "Honduras", + "tag": "es-HN", + "LCID": "18442" + }, + { + "language": "Spanish", + "Geographic area": "Mexico", + "tag": "es-MX", + "LCID": "2058" + }, + { + "language": "Spanish", + "Geographic area": "Nicaragua", + "tag": "es-NI", + "LCID": "19466" + }, + { + "language": "Spanish", + "Geographic area": "Panama", + "tag": "es-PA", + "LCID": "6154" + }, + { + "language": "Spanish", + "Geographic area": "Paraguay", + "tag": "es-PY", + "LCID": "15370" + }, + { + "language": "Spanish", + "Geographic area": "Peru", + "tag": "es-PE", + "LCID": "10250" + }, + { + "language": "Spanish", + "Geographic area": "Uruguay", + "tag": "es-UY", + "LCID": "14346" + }, + { + "language": "Spanish", + "Geographic area": "Venezuela", + "tag": "es-VE", + "LCID": "8202" + }, { "language": "Swedish", "Geographic area": "Sweden", @@ -229,14 +487,26 @@ }, { "language": "Ukrainian", - "Geographic area": "Ukrainian", + "Geographic area": "Ukraine", "tag": "uk-UA", "LCID": "1058" }, + { + "language": "Urdu", + "Geographic area": "Pakistan", + "tag": "ur-PK", + "LCID": "1056" + }, { "language": "Vietnamese", "Geographic area": "Vietnam", "tag": "vi-VN", "LCID": "1066" + }, + { + "language": "Welsh", + "Geographic area": "Wales", + "tag": "cy-GB", + "LCID": "1106" } -] +] \ No newline at end of file diff --git a/public/version.json b/public/version.json index 8857000ffcc6..7dce10929b8b 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "8.1.0" -} + "version": "8.1.1" +} \ No newline at end of file diff --git a/src/components/CippComponents/AppApprovalTemplateForm.jsx b/src/components/CippComponents/AppApprovalTemplateForm.jsx index 7ddedee2c19e..ac2377b3734d 100644 --- a/src/components/CippComponents/AppApprovalTemplateForm.jsx +++ b/src/components/CippComponents/AppApprovalTemplateForm.jsx @@ -171,10 +171,10 @@ const AppApprovalTemplateForm = ({ <> App approval templates allow you to define an application with its permissions that - can be deployed to multiple tenants. Select an application and permission set to - create a template. + can be deployed to multiple tenants. Select a multi-tenant application and + permission set to create a template. If your application is not listed, check the + Supported account types in the App Registration properties in Entra. - - { + return data.filter( + (item) => item.addedFields?.signInAudience === "AzureADMultipleOrgs" + ); }, showRefresh: true, }} multiple={false} + creatable={false} + required={true} validators={{ required: "Application is required" }} /> @@ -219,6 +226,8 @@ const AppApprovalTemplateForm = ({ showRefresh: true, }} multiple={false} + creatable={false} + required={true} validators={{ required: "Permission Set is required" }} /> diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index 44f5d7b3a77e..c1a92c76d0e4 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -8,6 +8,8 @@ import { FormControl, FormLabel, RadioGroup, + Button, + Box, } from "@mui/material"; import { CippAutoComplete } from "./CippAutocomplete"; import { Controller, useFormState } from "react-hook-form"; @@ -25,6 +27,7 @@ import { import StarterKit from "@tiptap/starter-kit"; import { CippDataTable } from "../CippTable/CippDataTable"; import React from "react"; +import { AccessTime } from "@mui/icons-material"; // Helper function to convert bracket notation to dot notation // Improved to correctly handle nested bracket notations @@ -414,38 +417,66 @@ export const CippFormComponent = (props) => { control={formControl.control} rules={validators} render={({ field }) => ( - { - if (date) { - const unixTimestamp = Math.floor(date.getTime() / 1000); // Convert to Unix timestamp - field.onChange(unixTimestamp); // Pass the Unix timestamp to the form - } else { - field.onChange(null); // Handle the case where no date is selected - } - }} - ampm={false} - minutesStep={15} - inputFormat="yyyy/MM/dd HH:mm" // Display format - renderInput={(inputProps) => ( - + + { + if (date) { + const unixTimestamp = Math.floor(date.getTime() / 1000); // Convert to Unix timestamp + field.onChange(unixTimestamp); // Pass the Unix timestamp to the form + } else { + field.onChange(null); // Handle the case where no date is selected + } + }} + ampm={false} + minutesStep={15} + inputFormat="yyyy/MM/dd HH:mm" // Display format + renderInput={(inputProps) => ( + + )} {...other} - fullWidth - error={!!errors[convertedName]} - helperText={get(errors, convertedName, {})?.message} - variant="filled" /> - )} - {...other} - /> + + + )} /> diff --git a/src/components/CippComponents/CippFormUserSelector.jsx b/src/components/CippComponents/CippFormUserSelector.jsx index 285e8a805953..ed9912c5338f 100644 --- a/src/components/CippComponents/CippFormUserSelector.jsx +++ b/src/components/CippComponents/CippFormUserSelector.jsx @@ -30,7 +30,9 @@ export const CippFormUserSelector = ({ dataKey: "Results", labelField: (option) => `${option.displayName} (${option.userPrincipalName})`, valueField: valueField ? valueField : "id", - queryKey: `ListUsers-${currentTenant?.value ? currentTenant.value : selectedTenant}`, + queryKey: `ListUsers-${currentTenant?.value ? currentTenant.value : selectedTenant}-${ + select ? select : "default" + }`, data: { Endpoint: "users", manualPagination: true, diff --git a/src/components/CippComponents/CippMailboxPermissionsDialog.jsx b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx index f27ba1a0bfa8..8306089a8008 100644 --- a/src/components/CippComponents/CippMailboxPermissionsDialog.jsx +++ b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx @@ -1,13 +1,24 @@ import { Box, Stack } from "@mui/material"; +import { useEffect } from "react"; import CippFormComponent from "./CippFormComponent"; import { useWatch } from "react-hook-form"; -const CippMailboxPermissionsDialog = ({ formHook, combinedOptions, isUserGroupLoading }) => { +const CippMailboxPermissionsDialog = ({ + formHook, + combinedOptions, + isUserGroupLoading, + defaultAutoMap = false +}) => { const fullAccess = useWatch({ control: formHook.control, name: "permissions.AddFullAccess", }); + // Set the default AutoMap value when component mounts + useEffect(() => { + formHook.setValue("permissions.AutoMap", defaultAutoMap); + }, [formHook, defaultAutoMap]); + return ( diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index e1b42686c2ee..6749d78ff445 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -254,8 +254,8 @@ const CippAddEditUser = (props) => { @@ -263,8 +263,8 @@ const CippAddEditUser = (props) => { @@ -272,8 +272,17 @@ const CippAddEditUser = (props) => { + + + diff --git a/src/components/CippFormPages/CippSchedulerForm.jsx b/src/components/CippFormPages/CippSchedulerForm.jsx index c7492a6e4eec..29878fc449ee 100644 --- a/src/components/CippFormPages/CippSchedulerForm.jsx +++ b/src/components/CippFormPages/CippSchedulerForm.jsx @@ -65,7 +65,11 @@ const CippSchedulerForm = (props) => { const router = useRouter(); const scheduledTaskList = ApiGetCall({ url: "/api/ListScheduledItems", - queryKey: "ListScheduledItems-Edit", + queryKey: "ListScheduledItems-Edit-" + router.query.id, + waiting: !!router.query.id, + data: { + Id: router.query.id, + }, }); const tenantList = ApiGetCall({ @@ -75,6 +79,13 @@ const CippSchedulerForm = (props) => { useEffect(() => { if (scheduledTaskList.isSuccess && router.query.id) { const task = scheduledTaskList.data.find((task) => task.RowKey === router.query.id); + + // Early return if task is not found + if (!task) { + console.warn(`Task with RowKey ${router.query.id} not found`); + return; + } + const postExecution = task?.postExecution?.split(",").map((item) => { return { label: item, value: item }; }); @@ -86,10 +97,29 @@ const CippSchedulerForm = (props) => { ); if (commands.isSuccess) { const command = commands.data.find((command) => command.Function === task.Command); + + // If command is not found in the list, create a placeholder command entry + let commandForForm = command; + if (!command && task.Command) { + commandForForm = { + Function: task.Command, + Parameters: [], + // Add minimal required structure for system jobs + }; + } + var recurrence = recurrenceOptions.find( (option) => option.value === task.Recurrence || option.label === task.Recurrence ); + // If recurrence is not found in predefined options, create a custom option + if (!recurrence && task.Recurrence) { + recurrence = { + value: task.Recurrence, + label: `${task.Recurrence}`, + }; + } + // if scheduledtime type is a date, convert to unixtime if (typeof task.ScheduledTime === "date") { task.ScheduledTime = Math.floor(task.ScheduledTime.getTime() / 1000); @@ -104,12 +134,15 @@ const CippSchedulerForm = (props) => { }, RowKey: router.query.Clone ? null : task.RowKey, Name: router.query.Clone ? `${task.Name} (Clone)` : task?.Name, - command: { label: task.Command, value: task.Command, addedFields: command }, + command: { label: task.Command, value: task.Command, addedFields: commandForForm }, ScheduledTime: task.ScheduledTime, Recurrence: recurrence, parameters: task.Parameters, postExecution: postExecution, - advancedParameters: task.RawJsonParameters ? true : false, + // Show advanced parameters if RawJsonParameters exist OR if it's a system command with no defined parameters + advancedParameters: task.RawJsonParameters + ? true + : !commandForForm?.Parameters || commandForForm.Parameters.length === 0, }; formControl.reset(ResetParams); } @@ -128,13 +161,19 @@ const CippSchedulerForm = (props) => { useEffect(() => { if (advancedParameters === true) { var schedulerValues = formControl.getValues("parameters"); - Object.keys(schedulerValues).forEach((key) => { - if (schedulerValues[key] === "" || schedulerValues[key] === null) { - delete schedulerValues[key]; - } - }); - const jsonString = JSON.stringify(schedulerValues, null, 2); - formControl.setValue("RawJsonParameters", jsonString); + // Add null check to prevent error when no command is selected + if (schedulerValues && typeof schedulerValues === "object") { + Object.keys(schedulerValues).forEach((key) => { + if (schedulerValues[key] === "" || schedulerValues[key] === null) { + delete schedulerValues[key]; + } + }); + const jsonString = JSON.stringify(schedulerValues, null, 2); + formControl.setValue("RawJsonParameters", jsonString); + } else { + // If no parameters, set empty object + formControl.setValue("RawJsonParameters", "{}"); + } } }, [advancedParameters]); @@ -174,15 +213,33 @@ const CippSchedulerForm = (props) => { required={true} formControl={formControl} isFetching={commands.isFetching} - options={ - commands.data?.map((command) => { - return { - label: command.Function, - value: command.Function, - addedFields: command, - }; - }) || [] - } + options={(() => { + const baseOptions = + commands.data?.map((command) => { + return { + label: command.Function, + value: command.Function, + addedFields: command, + }; + }) || []; + + // If we're editing a task and the command isn't in the base options, add it + if (router.query.id && scheduledTaskList.isSuccess) { + const task = scheduledTaskList.data.find((task) => task.RowKey === router.query.id); + if (task?.Command && !baseOptions.find((opt) => opt.value === task.Command)) { + baseOptions.unshift({ + label: task.Command, + value: task.Command, + addedFields: { + Function: task.Command, + Parameters: [], + }, + }); + } + } + + return baseOptions; + })()} validators={{ validate: (value) => { if (!value) { @@ -211,9 +268,25 @@ const CippSchedulerForm = (props) => { name="Recurrence" label="Recurrence" formControl={formControl} - options={recurrenceOptions} + options={(() => { + let options = [...recurrenceOptions]; + + // If we're editing a task and the recurrence isn't in the base options, add it + if (router.query.id && scheduledTaskList.isSuccess) { + const task = scheduledTaskList.data.find((task) => task.RowKey === router.query.id); + if (task?.Recurrence && !options.find((opt) => opt.value === task.Recurrence)) { + options.push({ + value: task.Recurrence, + label: `Custom: ${task.Recurrence}`, + }); + } + } + + return options; + })()} multiple={false} disableClearable={true} + creatable={true} /> {selectedCommand?.addedFields?.Synopsis && ( diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index 06f8cd2acb12..8e1532560be3 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -41,6 +41,7 @@ import GDAPRoles from "/src/data/GDAPRoles"; import timezoneList from "/src/data/timezoneList"; import standards from "/src/data/standards.json"; import { CippFormCondition } from "../CippComponents/CippFormCondition"; +import ReactMarkdown from "react-markdown"; const getAvailableActions = (disabledFeatures) => { const allActions = [ @@ -675,9 +676,42 @@ const CippStandardAccordion = ({ sx={{ mr: 1 }} /> - - {standard.helpText} - + theme.palette.primary.main, + textDecoration: "underline", + "&:hover": { + textDecoration: "none", + }, + }, + color: "text.secondary", + fontSize: "0.875rem", + lineHeight: 1.43, + mr: 1, + }} + > + ( + + {children} + + ), + // Convert paragraphs to spans to avoid unwanted spacing + p: ({ children }) => {children}, + }} + > + {standard.helpText} + + diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx index 2db8c9b4b083..3eeca3408a4d 100644 --- a/src/components/CippStandards/CippStandardDialog.jsx +++ b/src/components/CippStandards/CippStandardDialog.jsx @@ -15,12 +15,20 @@ import { Button, IconButton, CircularProgress, + Select, + MenuItem, + FormControl, + InputLabel, + Stack, + Divider, + Collapse, } from "@mui/material"; import { Grid } from "@mui/system"; -import { Add } from "@mui/icons-material"; +import { Add, Sort, Clear, FilterList, ExpandMore, ExpandLess } from "@mui/icons-material"; import { useState, useCallback, useMemo, memo, useEffect } from "react"; import { debounce } from "lodash"; import { Virtuoso } from "react-virtuoso"; +import ReactMarkdown from "react-markdown"; // Memoized Standard Card component to prevent unnecessary re-renders const StandardCard = memo( @@ -72,20 +80,49 @@ const StandardCard = memo( }, [standard.name]); return ( - + + + {isNewStandard(standard.addedDate) && ( + + )} - {isNewStandard(standard.addedDate) && ( - - )} {standard.label} @@ -95,9 +132,42 @@ const StandardCard = memo( Description: - - {standard.helpText} - + theme.palette.primary.main, + textDecoration: "underline", + "&:hover": { + textDecoration: "none", + }, + }, + color: "text.secondary", + fontSize: "0.875rem", + lineHeight: 1.43, + mb: 2, + }} + > + ( + + {children} + + ), + // Convert paragraphs to spans to avoid unwanted spacing + p: ({ children }) => {children}, + }} + > + {standard.helpText} + + )} @@ -191,6 +261,7 @@ const StandardCard = memo( )} + ); }, @@ -250,7 +321,16 @@ const VirtualizedStandardGrid = memo(({ items, renderItem }) => { defaultItemHeight={320} // Provide estimated row height for better virtualization itemContent={(index) => ( - + {rows[index].map(renderItem)} @@ -274,6 +354,228 @@ const CippStandardDialog = ({ const [isButtonDisabled, setButtonDisabled] = useState(false); const [localSearchQuery, setLocalSearchQuery] = useState(""); const [isInitialLoading, setIsInitialLoading] = useState(true); + + // Enhanced filtering and sorting state + const [sortBy, setSortBy] = useState("addedDate"); // Default sort by date added + const [sortOrder, setSortOrder] = useState("desc"); // desc to show newest first + const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedImpacts, setSelectedImpacts] = useState([]); + const [selectedRecommendedBy, setSelectedRecommendedBy] = useState([]); + const [selectedTagFrameworks, setSelectedTagFrameworks] = useState([]); + const [showOnlyNew, setShowOnlyNew] = useState(false); // Show only standards added in last 30 days + const [filtersExpanded, setFiltersExpanded] = useState(true); // Control filter section collapse/expand + + // Auto-adjust sort order when sort type changes + useEffect(() => { + if (sortBy === "label") { + setSortOrder("asc"); // Names: A-Z + } else if (sortBy === "addedDate") { + setSortOrder("desc"); // Dates: Newest first + } else if (sortBy === "impact") { + setSortOrder("desc"); // Impact: High to Low + } + }, [sortBy]); + + // Get all unique values for filters + const { allCategories, allImpacts, allRecommendedBy, allTagFrameworks } = useMemo(() => { + const categorySet = new Set(); + const impactSet = new Set(); + const recommendedBySet = new Set(); + const tagFrameworkSet = new Set(); + + // Function to extract base framework from tag + const extractTagFramework = (tag) => { + // Compliance Frameworks - extract version dynamically + if (tag.startsWith('CIS M365')) { + const versionMatch = tag.match(/CIS M365 (\d+\.\d+)/); + return versionMatch ? `CIS M365 ${versionMatch[1]}` : 'CIS M365'; + } + if (tag.startsWith('CISA ')) return 'CISA'; + if (tag.startsWith('EIDSCA.')) return 'EIDSCA'; + if (tag.startsWith('Essential 8')) return 'Essential 8'; + if (tag.startsWith('NIST CSF')) { + const versionMatch = tag.match(/NIST CSF (\d+\.\d+)/); + return versionMatch ? `NIST CSF ${versionMatch[1]}` : 'NIST CSF'; + } + + // Microsoft Secure Score Categories + if (tag.startsWith('exo_')) return 'Secure Score - Exchange'; + if (tag.startsWith('mdo_')) return 'Secure Score - Defender'; + if (tag.startsWith('spo_')) return 'Secure Score - SharePoint'; + if (tag.startsWith('mip_')) return 'Secure Score - Purview'; + + // For any other tags, return null to exclude them + return null; + }; + + Object.keys(categories).forEach((category) => { + categorySet.add(category); + categories[category].forEach((standard) => { + if (standard.impact) impactSet.add(standard.impact); + if (standard.recommendedBy && Array.isArray(standard.recommendedBy)) { + standard.recommendedBy.forEach(rec => recommendedBySet.add(rec)); + } + // Process tags to extract frameworks + if (standard.tag && Array.isArray(standard.tag)) { + standard.tag.forEach(tag => { + const framework = extractTagFramework(tag); + if (framework) { // Only add non-null frameworks + tagFrameworkSet.add(framework); + } + }); + } + }); + }); + + // Custom sort order for impacts: Low -> Medium -> High + const impactOrder = ["Low Impact", "Medium Impact", "High Impact"]; + const sortedImpacts = Array.from(impactSet).sort((a, b) => { + const aIndex = impactOrder.indexOf(a); + const bIndex = impactOrder.indexOf(b); + return aIndex - bIndex; + }); + + // Sort tag frameworks with compliance frameworks first, then service categories + const sortedTagFrameworks = Array.from(tagFrameworkSet).sort((a, b) => { + // Define priority groups + const getFrameworkPriority = (framework) => { + if (framework.startsWith('CIS M365')) return 1; + if (framework === 'CISA') return 2; + if (framework === 'EIDSCA') return 3; + if (framework === 'Essential 8') return 4; + if (framework.startsWith('NIST CSF')) return 5; + if (framework.startsWith('Secure Score -')) return 6; + return 999; // Other tags go last + }; + + const aPriority = getFrameworkPriority(a); + const bPriority = getFrameworkPriority(b); + + // If different priorities, sort by priority + if (aPriority !== bPriority) { + return aPriority - bPriority; + } + + // If same priority, sort alphabetically + return a.localeCompare(b); + }); + + return { + allCategories: Array.from(categorySet).sort(), + allImpacts: sortedImpacts, + allRecommendedBy: Array.from(recommendedBySet).sort(), + allTagFrameworks: sortedTagFrameworks, + }; + }, [categories]); + + // Enhanced filter function + const enhancedFilterStandards = useCallback((standardsList) => { + // Function to extract base framework from tag (same as in useMemo) + const extractTagFramework = (tag) => { + // Compliance Frameworks - extract version dynamically + if (tag.startsWith('CIS M365')) { + const versionMatch = tag.match(/CIS M365 (\d+\.\d+)/); + return versionMatch ? `CIS M365 ${versionMatch[1]}` : 'CIS M365'; + } + if (tag.startsWith('CISA ')) return 'CISA'; + if (tag.startsWith('EIDSCA.')) return 'EIDSCA'; + if (tag.startsWith('Essential 8')) return 'Essential 8'; + if (tag.startsWith('NIST CSF')) { + const versionMatch = tag.match(/NIST CSF (\d+\.\d+)/); + return versionMatch ? `NIST CSF ${versionMatch[1]}` : 'NIST CSF'; + } + + // Microsoft Secure Score Categories + if (tag.startsWith('exo_')) return 'Secure Score - Exchange'; + if (tag.startsWith('mdo_')) return 'Secure Score - Defender'; + if (tag.startsWith('spo_')) return 'Secure Score - SharePoint'; + if (tag.startsWith('mip_')) return 'Secure Score - Purview'; + + // For any other tags, return null to exclude them + return null; + }; + + return standardsList.filter((standard) => { + // Original text search + const matchesSearch = !localSearchQuery || + standard.label.toLowerCase().includes(localSearchQuery.toLowerCase()) || + standard.helpText.toLowerCase().includes(localSearchQuery.toLowerCase()) || + (standard.tag && standard.tag.some((tag) => + tag.toLowerCase().includes(localSearchQuery.toLowerCase()) + )); + + // Category filter + const matchesCategory = selectedCategories.length === 0 || + selectedCategories.includes(standard.cat); + + // Impact filter + const matchesImpact = selectedImpacts.length === 0 || + selectedImpacts.includes(standard.impact); + + // Recommended by filter + const matchesRecommendedBy = selectedRecommendedBy.length === 0 || + (standard.recommendedBy && Array.isArray(standard.recommendedBy) && + standard.recommendedBy.some(rec => selectedRecommendedBy.includes(rec))); + + // Tag framework filter + const matchesTagFramework = selectedTagFrameworks.length === 0 || + (standard.tag && Array.isArray(standard.tag) && + standard.tag.some(tag => { + const framework = extractTagFramework(tag); + return framework && selectedTagFrameworks.includes(framework); + })); + + // New standards filter (last 30 days) + const isNewStandard = (dateAdded) => { + if (!dateAdded) return false; + const currentDate = new Date(); + const addedDate = new Date(dateAdded); + return differenceInDays(currentDate, addedDate) <= 30; + }; + const matchesNewFilter = !showOnlyNew || isNewStandard(standard.addedDate); + + return matchesSearch && matchesCategory && matchesImpact && matchesRecommendedBy && matchesTagFramework && matchesNewFilter; + }); + }, [localSearchQuery, selectedCategories, selectedImpacts, selectedRecommendedBy, selectedTagFrameworks, showOnlyNew]); + + // Enhanced sort function + const sortStandards = useCallback((standardsList) => { + return [...standardsList].sort((a, b) => { + let aValue, bValue; + + switch (sortBy) { + case "label": + aValue = a.label.toLowerCase(); + bValue = b.label.toLowerCase(); + break; + case "addedDate": + aValue = new Date(a.addedDate || "1900-01-01"); + bValue = new Date(b.addedDate || "1900-01-01"); + break; + case "category": + aValue = a.cat?.toLowerCase() || ""; + bValue = b.cat?.toLowerCase() || ""; + break; + case "impact": + // Sort by impact priority: High > Medium > Low + const impactOrder = { "High Impact": 3, "Medium Impact": 2, "Low Impact": 1 }; + aValue = impactOrder[a.impact] || 0; + bValue = impactOrder[b.impact] || 0; + break; + case "recommendedBy": + aValue = (a.recommendedBy && a.recommendedBy.length > 0) ? a.recommendedBy.join(", ").toLowerCase() : ""; + bValue = (b.recommendedBy && b.recommendedBy.length > 0) ? b.recommendedBy.join(", ").toLowerCase() : ""; + break; + default: + aValue = a.label.toLowerCase(); + bValue = b.label.toLowerCase(); + } + + if (aValue < bValue) return sortOrder === "asc" ? -1 : 1; + if (aValue > bValue) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + }, [sortBy, sortOrder]); // Optimize handleAddClick to be more performant const handleAddClick = useCallback( @@ -304,16 +606,34 @@ const CippStandardDialog = ({ // Handle search input change locally const handleLocalSearchChange = useCallback( (e) => { - const value = e.target.value.toLowerCase(); + const value = e.target.value; setLocalSearchQuery(value); handleSearchQueryChange(value); }, [handleSearchQueryChange] ); + // Clear all filters + const clearAllFilters = useCallback(() => { + setLocalSearchQuery(""); + setSelectedCategories([]); + setSelectedImpacts([]); + setSelectedRecommendedBy([]); + setSelectedTagFrameworks([]); + setShowOnlyNew(false); + setSortBy("addedDate"); + setSortOrder("desc"); + handleSearchQueryChange(""); + }, [handleSearchQueryChange]); + // Clear dialog state on close const handleClose = useCallback(() => { setLocalSearchQuery(""); // Clear local search state + setSelectedCategories([]); + setSelectedImpacts([]); + setSelectedRecommendedBy([]); + setSelectedTagFrameworks([]); + setShowOnlyNew(false); handleSearchQueryChange(""); // Clear parent search state handleCloseDialog(); }, [handleCloseDialog, handleSearchQueryChange]); @@ -327,7 +647,9 @@ const CippStandardDialog = ({ const allItems = []; Object.keys(categories).forEach((category) => { - const filteredStandards = filterStandards(categories[category]); + const categoryStandards = categories[category]; + const filteredStandards = enhancedFilterStandards(categoryStandards); + filteredStandards.forEach((standard) => { allItems.push({ standard, @@ -336,7 +658,13 @@ const CippStandardDialog = ({ }); }); - setProcessedItems(allItems); + // Apply sorting to the final combined array instead of per-category + const sortedAllItems = sortStandards(allItems.map(item => item.standard)).map(standard => { + const item = allItems.find(item => item.standard.name === standard.name); + return item; + }); + + setProcessedItems(sortedAllItems); setIsInitialLoading(false); }; @@ -354,7 +682,7 @@ const CippStandardDialog = ({ } else { setIsInitialLoading(true); } - }, [dialogOpen, categories, filterStandards, localSearchQuery]); + }, [dialogOpen, categories, enhancedFilterStandards, sortStandards]); // Render individual standard card const renderStandardCard = useCallback( @@ -372,6 +700,9 @@ const CippStandardDialog = ({ [selectedStandards, handleToggleSingleStandard, handleAddClick, isButtonDisabled] ); + // Count active filters + const activeFiltersCount = selectedCategories.length + selectedImpacts.length + selectedRecommendedBy.length + selectedTagFrameworks.length + (showOnlyNew ? 1 : 0); + // Don't render dialog contents until it's actually open (improves performance) return ( Select a Standard to Add + {/* Search and Filter Controls */} + + {/* Search Box */} + placeholder="Search by name, description, or tags..." + sx={{ mb: 3 }} + /> + + {/* Filter Controls Section */} + + {/* Clickable header bar */} + setFiltersExpanded(!filtersExpanded)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + mb: 2, + p: 1, + borderRadius: 1, + cursor: 'pointer', + bgcolor: 'action.hover', + '&:hover': { + bgcolor: 'action.selected' + } + }} + > + + Sort & Filter Options + + {filtersExpanded ? : } + + + {/* Compact summary when collapsed */} + {!filtersExpanded && ( + + + Sorted by {sortBy === 'addedDate' ? 'Date Added' : 'Name'} ({sortOrder === 'desc' ? 'Desc' : 'Asc'}) + + {activeFiltersCount > 0 && ( + <> + + • {activeFiltersCount} filter{activeFiltersCount !== 1 ? 's' : ''} + + + + )} + + )} + + {/* Collapsible filter controls */} + + + {/* Sort Controls Card */} + + + SORT: + + + Sort By + + + + + Order + + + + + {/* Filter Controls Card */} + + + FILTER: + + + Categories + + + + + Impact + + + + + Recommended By + + + + Compliance Tags + + + + {/* New Standards Toggle */} + setShowOnlyNew(e.target.checked)} + /> + } + label="New (30 days)" + sx={{ ml: 1 }} + /> + + {/* Clear All Filters Button */} + {activeFiltersCount > 0 && ( + + )} + + + + + + {/* Active Filter Chips */} + {activeFiltersCount > 0 && ( + + + {selectedCategories.map((category) => ( + setSelectedCategories(prev => prev.filter(c => c !== category))} + color="primary" + variant="outlined" + /> + ))} + {selectedImpacts.map((impact) => ( + setSelectedImpacts(prev => prev.filter(i => i !== impact))} + color="secondary" + variant="outlined" + /> + ))} + {selectedRecommendedBy.map((rec) => ( + setSelectedRecommendedBy(prev => prev.filter(r => r !== rec))} + color="success" + variant="outlined" + /> + ))} + {selectedTagFrameworks.map((framework) => ( + setSelectedTagFrameworks(prev => prev.filter(f => f !== framework))} + color="warning" + variant="outlined" + /> + ))} + {showOnlyNew && ( + setShowOnlyNew(false)} + color="info" + variant="outlined" + /> + )} + + + )} + + + + + {/* Results */} {isInitialLoading ? ( ) : processedItems.length === 0 ? ( - - Search returned no results + + + No standards match your search and filter criteria + + + Try adjusting your search terms or clearing some filters + ) : ( + + + Showing {processedItems.length} standard{processedItems.length !== 1 ? 's' : ''} + + )} diff --git a/src/components/CippWizard/CippWizardAutopilotImport.jsx b/src/components/CippWizard/CippWizardAutopilotImport.jsx new file mode 100644 index 000000000000..2c3384686ada --- /dev/null +++ b/src/components/CippWizard/CippWizardAutopilotImport.jsx @@ -0,0 +1,487 @@ +import { + Button, + Grid, + Link, + Stack, + Card, + CardContent, + Box, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Alert, +} from "@mui/material"; +import { CippWizardStepButtons } from "./CippWizardStepButtons"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import { useWatch } from "react-hook-form"; +import { Delete, FileDownload, Upload, Add } from "@mui/icons-material"; +import { useEffect, useState } from "react"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import React from "react"; + +export const CippWizardAutopilotImport = (props) => { + const { + onNextStep, + formControl, + currentStep, + onPreviousStep, + fields, + name, + nameToCSVMapping, + fileName = "template", + } = props; + const tableData = useWatch({ control: formControl.control, name: name }); + const [newTableData, setTableData] = useState([]); + const fileInputRef = React.useRef(null); + const [manualDialogOpen, setManualDialogOpen] = useState(false); + const [manualInputs, setManualInputs] = useState([{}]); + const inputRefs = React.useRef([]); + const [validationErrors, setValidationErrors] = useState([]); + + const handleRemoveItem = (row) => { + if (row === undefined) return false; + const index = tableData?.findIndex((item) => item === row); + const newTableData = [...tableData]; + newTableData.splice(index, 1); + setTableData(newTableData); + }; + + const handleFileSelect = (event) => { + const file = event.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target.result; + const lines = text.split('\n'); + const firstLine = lines[0].split(',').map(header => header.trim()); + + // Check if this is a headerless CSV (no recognizable headers) + const hasHeaders = firstLine.some(header => { + // Check if any header matches our expected field names + return fields.some(field => + header === field.propertyName || + header === field.friendlyName || + (field.alternativePropertyNames && field.alternativePropertyNames.includes(header)) + ); + }); + + let headers, headerMapping; + + if (hasHeaders) { + // Normal CSV with headers + headers = firstLine; + + // Create mapping for property names and alternative property names + headerMapping = {}; + fields.forEach(field => { + // Map primary property name to itself + headerMapping[field.propertyName] = field.propertyName; + // Map friendly name to property name + headerMapping[field.friendlyName] = field.propertyName; + // Map alternative property names to the primary property name + if (field.alternativePropertyNames) { + field.alternativePropertyNames.forEach(altName => { + headerMapping[altName] = field.propertyName; + }); + } + }); + + // Check if all required columns are present (using any of the supported formats) + const missingColumns = fields.filter(field => { + // Only serial number is required + if (field.propertyName !== 'SerialNumber') { + return false; // Skip non-required fields + } + + const hasPropertyName = headers.includes(field.propertyName); + const hasFriendlyName = headers.includes(field.friendlyName); + const hasAlternativeName = field.alternativePropertyNames ? + field.alternativePropertyNames.some(altName => headers.includes(altName)) : false; + return !hasPropertyName && !hasFriendlyName && !hasAlternativeName; + }); + + if (missingColumns.length > 0) { + const missingFormats = missingColumns.map(f => { + const formats = [f.propertyName, f.friendlyName]; + if (f.alternativePropertyNames) { + formats.push(...f.alternativePropertyNames); + } + return `"${formats.join('" or "')}"`; + }).join(', '); + console.error(`CSV is missing required columns: ${missingFormats}`); + return; + } + } else { + // Headerless CSV - assume order: serial, productid, hash + headers = ['SerialNumber', 'productKey', 'hardwareHash']; + headerMapping = { + 'SerialNumber': 'SerialNumber', + 'productKey': 'productKey', + 'hardwareHash': 'hardwareHash' + }; + + // Check if we have at least 3 columns for the expected order + if (firstLine.length < 3) { + console.error('Headerless CSV must have at least 3 columns in order: Serial Number, Product ID, Hardware Hash'); + return; + } + } + + const data = lines.slice(hasHeaders ? 1 : 0) // Skip first line only if it has headers + .filter(line => line.trim() !== '') // Remove empty lines + .map(line => { + const values = line.split(','); + // Initialize with all fields as empty strings + const row = fields.reduce((obj, field) => { + obj[field.propertyName] = ''; + return obj; + }, {}); + // Fill in the values from the CSV + headers.forEach((header, i) => { + const propertyName = headerMapping[header]; + if (propertyName) { + row[propertyName] = values[i]?.trim() || ''; + } + }); + return row; + }); + + setTableData(data); + formControl.setValue(name, data, { shouldValidate: true }); + }; + reader.readAsText(file); + } + }; + + const handleManualInputChange = (rowIndex, field, value) => { + setManualInputs(prev => { + const newInputs = [...prev]; + if (!newInputs[rowIndex]) { + newInputs[rowIndex] = {}; + } + newInputs[rowIndex][field] = value; + return newInputs; + }); + }; + + const handleAddRow = () => { + setManualInputs(prev => [...prev, {}]); + }; + + const validateRows = (rows) => { + const errors = []; + const seenSerials = new Set(); + const seenProductKeys = new Set(); + + rows.forEach((row, index) => { + const serialField = fields.find(f => f.propertyName === 'SerialNumber'); + const productKeyField = fields.find(f => f.propertyName === 'productKey'); + const manufacturerField = fields.find(f => f.propertyName === 'oemManufacturerName'); + const modelField = fields.find(f => f.propertyName === 'modelName'); + const hardwareHashField = fields.find(f => f.propertyName === 'hardwareHash'); + + if (serialField && row[serialField.propertyName] && seenSerials.has(row[serialField.propertyName])) { + errors.push(`Row ${index + 1}: Duplicate serial number "${row[serialField.propertyName]}"`); + } + if (serialField && row[serialField.propertyName]) { + seenSerials.add(row[serialField.propertyName]); + } + + if (productKeyField && row[productKeyField.propertyName] && seenProductKeys.has(row[productKeyField.propertyName])) { + errors.push(`Row ${index + 1}: Duplicate product key "${row[productKeyField.propertyName]}"`); + } + if (productKeyField && row[productKeyField.propertyName]) { + seenProductKeys.add(row[productKeyField.propertyName]); + } + + // Validate Product ID length (must be exactly 13 characters) + if (productKeyField && row[productKeyField.propertyName] && row[productKeyField.propertyName].length !== 13) { + errors.push(`Row ${index + 1}: Product ID must be exactly 13 characters long`); + } + + // Validate Serial Number requirements: must have either Manufacturer+Model OR Hardware Hash + if (serialField && row[serialField.propertyName] && row[serialField.propertyName].trim() !== '') { + const hasManufacturer = manufacturerField && row[manufacturerField.propertyName] && row[manufacturerField.propertyName].trim() !== ''; + const hasModel = modelField && row[modelField.propertyName] && row[modelField.propertyName].trim() !== ''; + const hasHardwareHash = hardwareHashField && row[hardwareHashField.propertyName] && row[hardwareHashField.propertyName].trim() !== ''; + + const hasManufacturerAndModel = hasManufacturer && hasModel; + const hasHash = hasHardwareHash; + + if (!hasManufacturerAndModel && !hasHash) { + errors.push(`Row ${index + 1}: Serial Number must be accompanied by either both Manufacturer and Model, or Hardware Hash`); + } + } + }); + + setValidationErrors(errors); + return errors.length === 0; + }; + + const handleManualAdd = () => { + const newRows = manualInputs.filter(row => + Object.values(row).some(value => value && value.trim() !== '') + ).map(row => { + // Ensure all fields exist in the row + return fields.reduce((obj, field) => { + obj[field.propertyName] = row[field.propertyName] || ''; + return obj; + }, {}); + }); + + if (newRows.length === 0) { + setManualDialogOpen(false); + setManualInputs([{}]); + return; + } + + if (!validateRows(newRows)) { + return; + } + + const updatedData = [...tableData, ...newRows]; + setTableData(updatedData); + formControl.setValue(name, updatedData, { shouldValidate: true }); + setManualInputs([{}]); + setManualDialogOpen(false); + }; + + const handleDialogClose = () => { + setManualDialogOpen(false); + setManualInputs([{}]); + }; + + const handleKeyPress = (event, rowIndex) => { + const productKeyField = fields.find(f => f.propertyName === 'productKey'); + if (event.key === 'Enter' && productKeyField && manualInputs[rowIndex]?.[productKeyField.propertyName]) { + if (rowIndex === manualInputs.length - 1) { + const newRowIndex = manualInputs.length; + setManualInputs(prev => [...prev, {}]); + // Wait for the next render cycle to set focus + setTimeout(() => { + const newInput = inputRefs.current[newRowIndex]?.[productKeyField.propertyName]; + if (newInput) { + newInput.focus(); + } + }, 0); + } + } + }; + + const handleRemoveRow = (rowIndex) => { + setManualInputs(prev => prev.filter((_, index) => index !== rowIndex)); + }; + + useEffect(() => { + console.log('Table Data:', newTableData); + formControl.setValue(name, newTableData, { + shouldValidate: true, + }); + }, [newTableData]); + + // Add effect to validate rows when manualInputs changes + useEffect(() => { + validateRows(manualInputs); + }, [manualInputs]); + + const actions = [ + { + icon: , + label: "Delete Row", + confirmText: "Are you sure you want to delete this row?", + customFunction: handleRemoveItem, + noConfirm: true, + }, + ]; + + return ( + + f.propertyName)} + cardButton={ + + + + + + + } + /> + + + Manual Import + + + {validationErrors.length > 0 && ( + + + Please fix the following validation errors: + + {validationErrors.map((error, index) => ( + + • {error} + + ))} + + )} + {manualInputs.map((row, rowIndex) => ( + + {/* Row identifier */} + + {rowIndex + 1} + + {fields.map((field) => ( + + { + if (!inputRefs.current[rowIndex]) { + inputRefs.current[rowIndex] = {}; + } + inputRefs.current[rowIndex][field.propertyName] = el; + }} + label={field.friendlyName} + value={row[field.propertyName] || ''} + onChange={(e) => handleManualInputChange(rowIndex, field.propertyName, e.target.value)} + onKeyDown={(e) => field.propertyName === 'productKey' && handleKeyPress(e, rowIndex)} + fullWidth + size="small" + /> + + ))} + + + ))} + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippWizard/CippWizardCSVImport.jsx b/src/components/CippWizard/CippWizardCSVImport.jsx index 6213e3bccf4a..80983ad4f549 100644 --- a/src/components/CippWizard/CippWizardCSVImport.jsx +++ b/src/components/CippWizard/CippWizardCSVImport.jsx @@ -1,27 +1,12 @@ -import { - Button, - Grid, - Link, - Stack, - Card, - CardContent, - Box, - Typography, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - TextField, - Alert, -} from "@mui/material"; +import { Button, Link, Stack, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Grid } from "@mui/system"; import { CippWizardStepButtons } from "./CippWizardStepButtons"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { CippDataTable } from "../CippTable/CippDataTable"; import { useWatch } from "react-hook-form"; -import { Delete, FileDownload, Upload, Add } from "@mui/icons-material"; +import { Delete } from "@mui/icons-material"; import { useEffect, useState } from "react"; import { getCippTranslation } from "../../utils/get-cipp-translation"; -import React from "react"; export const CippWizardCSVImport = (props) => { const { @@ -31,16 +16,18 @@ export const CippWizardCSVImport = (props) => { onPreviousStep, fields, name, + manualFields = false, nameToCSVMapping, - fileName = "template", + fileName = "BulkUser", } = props; const tableData = useWatch({ control: formControl.control, name: name }); const [newTableData, setTableData] = useState([]); - const fileInputRef = React.useRef(null); - const [manualDialogOpen, setManualDialogOpen] = useState(false); - const [manualInputs, setManualInputs] = useState([{}]); - const inputRefs = React.useRef([]); - const [validationErrors, setValidationErrors] = useState([]); + const [open, setOpen] = useState(false); + + // Register form field with validation + formControl.register(name, { + validate: (value) => Array.isArray(value) && value.length > 0, + }); const handleRemoveItem = (row) => { if (row === undefined) return false; @@ -50,244 +37,20 @@ export const CippWizardCSVImport = (props) => { setTableData(newTableData); }; - const handleFileSelect = (event) => { - const file = event.target.files[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const text = e.target.result; - const lines = text.split('\n'); - const firstLine = lines[0].split(',').map(header => header.trim()); - - // Check if this is a headerless CSV (no recognizable headers) - const hasHeaders = firstLine.some(header => { - // Check if any header matches our expected field names - return fields.some(field => - header === field.propertyName || - header === field.friendlyName || - (field.alternativePropertyNames && field.alternativePropertyNames.includes(header)) - ); - }); - - let headers, headerMapping; - - if (hasHeaders) { - // Normal CSV with headers - headers = firstLine; - - // Create mapping for property names and alternative property names - headerMapping = {}; - fields.forEach(field => { - // Map primary property name to itself - headerMapping[field.propertyName] = field.propertyName; - // Map friendly name to property name - headerMapping[field.friendlyName] = field.propertyName; - // Map alternative property names to the primary property name - if (field.alternativePropertyNames) { - field.alternativePropertyNames.forEach(altName => { - headerMapping[altName] = field.propertyName; - }); - } - }); - - // Check if all required columns are present (using any of the supported formats) - const missingColumns = fields.filter(field => { - // Only serial number is required - if (field.propertyName !== 'SerialNumber') { - return false; // Skip non-required fields - } - - const hasPropertyName = headers.includes(field.propertyName); - const hasFriendlyName = headers.includes(field.friendlyName); - const hasAlternativeName = field.alternativePropertyNames ? - field.alternativePropertyNames.some(altName => headers.includes(altName)) : false; - return !hasPropertyName && !hasFriendlyName && !hasAlternativeName; - }); - - if (missingColumns.length > 0) { - const missingFormats = missingColumns.map(f => { - const formats = [f.propertyName, f.friendlyName]; - if (f.alternativePropertyNames) { - formats.push(...f.alternativePropertyNames); - } - return `"${formats.join('" or "')}"`; - }).join(', '); - console.error(`CSV is missing required columns: ${missingFormats}`); - return; - } - } else { - // Headerless CSV - assume order: serial, productid, hash - headers = ['SerialNumber', 'productKey', 'hardwareHash']; - headerMapping = { - 'SerialNumber': 'SerialNumber', - 'productKey': 'productKey', - 'hardwareHash': 'hardwareHash' - }; - - // Check if we have at least 3 columns for the expected order - if (firstLine.length < 3) { - console.error('Headerless CSV must have at least 3 columns in order: Serial Number, Product ID, Hardware Hash'); - return; - } - } - - const data = lines.slice(hasHeaders ? 1 : 0) // Skip first line only if it has headers - .filter(line => line.trim() !== '') // Remove empty lines - .map(line => { - const values = line.split(','); - // Initialize with all fields as empty strings - const row = fields.reduce((obj, field) => { - obj[field.propertyName] = ''; - return obj; - }, {}); - // Fill in the values from the CSV - headers.forEach((header, i) => { - const propertyName = headerMapping[header]; - if (propertyName) { - row[propertyName] = values[i]?.trim() || ''; - } - }); - return row; - }); - - setTableData(data); - formControl.setValue(name, data, { shouldValidate: true }); - }; - reader.readAsText(file); - } - }; - - const handleManualInputChange = (rowIndex, field, value) => { - setManualInputs(prev => { - const newInputs = [...prev]; - if (!newInputs[rowIndex]) { - newInputs[rowIndex] = {}; - } - newInputs[rowIndex][field] = value; - return newInputs; - }); - }; - - const handleAddRow = () => { - setManualInputs(prev => [...prev, {}]); - }; - - const validateRows = (rows) => { - const errors = []; - const seenSerials = new Set(); - const seenProductKeys = new Set(); - - rows.forEach((row, index) => { - const serialField = fields.find(f => f.propertyName === 'SerialNumber'); - const productKeyField = fields.find(f => f.propertyName === 'productKey'); - const manufacturerField = fields.find(f => f.propertyName === 'oemManufacturerName'); - const modelField = fields.find(f => f.propertyName === 'modelName'); - const hardwareHashField = fields.find(f => f.propertyName === 'hardwareHash'); - - if (serialField && row[serialField.propertyName] && seenSerials.has(row[serialField.propertyName])) { - errors.push(`Row ${index + 1}: Duplicate serial number "${row[serialField.propertyName]}"`); - } - if (serialField && row[serialField.propertyName]) { - seenSerials.add(row[serialField.propertyName]); - } - - if (productKeyField && row[productKeyField.propertyName] && seenProductKeys.has(row[productKeyField.propertyName])) { - errors.push(`Row ${index + 1}: Duplicate product key "${row[productKeyField.propertyName]}"`); - } - if (productKeyField && row[productKeyField.propertyName]) { - seenProductKeys.add(row[productKeyField.propertyName]); - } - - // Validate Product ID length (must be exactly 13 characters) - if (productKeyField && row[productKeyField.propertyName] && row[productKeyField.propertyName].length !== 13) { - errors.push(`Row ${index + 1}: Product ID must be exactly 13 characters long`); - } - - // Validate Serial Number requirements: must have either Manufacturer+Model OR Hardware Hash - if (serialField && row[serialField.propertyName] && row[serialField.propertyName].trim() !== '') { - const hasManufacturer = manufacturerField && row[manufacturerField.propertyName] && row[manufacturerField.propertyName].trim() !== ''; - const hasModel = modelField && row[modelField.propertyName] && row[modelField.propertyName].trim() !== ''; - const hasHardwareHash = hardwareHashField && row[hardwareHashField.propertyName] && row[hardwareHashField.propertyName].trim() !== ''; - - const hasManufacturerAndModel = hasManufacturer && hasModel; - const hasHash = hasHardwareHash; - - if (!hasManufacturerAndModel && !hasHash) { - errors.push(`Row ${index + 1}: Serial Number must be accompanied by either both Manufacturer and Model, or Hardware Hash`); - } - } - }); - - setValidationErrors(errors); - return errors.length === 0; - }; - - const handleManualAdd = () => { - const newRows = manualInputs.filter(row => - Object.values(row).some(value => value && value.trim() !== '') - ).map(row => { - // Ensure all fields exist in the row - return fields.reduce((obj, field) => { - obj[field.propertyName] = row[field.propertyName] || ''; - return obj; - }, {}); - }); - - if (newRows.length === 0) { - setManualDialogOpen(false); - setManualInputs([{}]); - return; - } - - if (!validateRows(newRows)) { - return; - } - - const updatedData = [...tableData, ...newRows]; - setTableData(updatedData); - formControl.setValue(name, updatedData, { shouldValidate: true }); - setManualInputs([{}]); - setManualDialogOpen(false); - }; - - const handleDialogClose = () => { - setManualDialogOpen(false); - setManualInputs([{}]); - }; - - const handleKeyPress = (event, rowIndex) => { - const productKeyField = fields.find(f => f.propertyName === 'productKey'); - if (event.key === 'Enter' && productKeyField && manualInputs[rowIndex]?.[productKeyField.propertyName]) { - if (rowIndex === manualInputs.length - 1) { - const newRowIndex = manualInputs.length; - setManualInputs(prev => [...prev, {}]); - // Wait for the next render cycle to set focus - setTimeout(() => { - const newInput = inputRefs.current[newRowIndex]?.[productKeyField.propertyName]; - if (newInput) { - newInput.focus(); - } - }, 0); - } - } - }; - - const handleRemoveRow = (rowIndex) => { - setManualInputs(prev => prev.filter((_, index) => index !== rowIndex)); + const handleAddItem = () => { + const newRowData = formControl.getValues("addrow"); + if (newRowData === undefined) return false; + const newTableData = [...tableData, newRowData]; + setTableData(newTableData); + setOpen(false); }; useEffect(() => { - console.log('Table Data:', newTableData); formControl.setValue(name, newTableData, { shouldValidate: true, }); }, [newTableData]); - // Add effect to validate rows when manualInputs changes - useEffect(() => { - validateRows(manualInputs); - }, [manualInputs]); - const actions = [ { icon: , @@ -300,182 +63,93 @@ export const CippWizardCSVImport = (props) => { return ( - f.propertyName)} - cardButton={ - - - - - - - } + + Download Example CSV + + - - - Manual Import - - - {validationErrors.length > 0 && ( - - - Please fix the following validation errors: - - {validationErrors.map((error, index) => ( - - • {error} - - ))} - - )} - {manualInputs.map((row, rowIndex) => ( - - {/* Row identifier */} - + {fields.map((field) => ( + <> + + { + if (e.key === "Enter") { + if (e.target.value === "") return false; + handleAddItem(); + setTimeout(() => { + formControl.setValue(`addrow.${field}`, ""); + }, 500); + } }} - > - {rowIndex + 1} - + /> + + + ))} + + + + + )} + {!manualFields && ( + <> + + + + + + setOpen(false)}> + Add a new row + + {fields.map((field) => ( - - { - if (!inputRefs.current[rowIndex]) { - inputRefs.current[rowIndex] = {}; - } - inputRefs.current[rowIndex][field.propertyName] = el; - }} - label={field.friendlyName} - value={row[field.propertyName] || ''} - onChange={(e) => handleManualInputChange(rowIndex, field.propertyName, e.target.value)} - onKeyDown={(e) => field.propertyName === 'productKey' && handleKeyPress(e, rowIndex)} - fullWidth - size="small" + + - + ))} - - - ))} - - - - - - - - - - + + + + + )} + { /> ); -}; +}; \ No newline at end of file diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx index 4ab449ba8546..5f217ee8b45e 100644 --- a/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/src/components/CippWizard/CippWizardOffboarding.jsx @@ -65,7 +65,7 @@ export const CippWizardOffboarding = (props) => { /> diff --git a/src/data/standards.json b/src/data/standards.json index 9a2137930d5c..6be68e4a46e0 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -1026,7 +1026,7 @@ { "name": "standards.StaleEntraDevices", "cat": "Entra (AAD) Standards", - "tag": ["CIS", "Essential 8 (1501)", "NIST CSF 2.0 (ID.AM-08)", "NIST CSF 2.0 (PR.PS-03)"], + "tag": ["Essential 8 (1501)", "NIST CSF 2.0 (ID.AM-08)", "NIST CSF 2.0 (PR.PS-03)"], "helpText": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days.", "docsDescription": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices)", "executiveText": "Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations.", @@ -1798,7 +1798,7 @@ { "name": "standards.AutoAddProxy", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [], "helpText": "Automatically adds all available domains as a proxy address.", "docsDescription": "Automatically finds all available domain names in the tenant, and tries to add proxy addresses based on the user's UPN to each of these.", "executiveText": "Automatically creates email addresses for employees across all company domains, ensuring they can receive emails sent to any of the organization's domain names. This improves email delivery reliability and maintains consistent communication channels across different business units or brands.", @@ -1833,7 +1833,7 @@ { "name": "standards.AntiSpamSafeList", "cat": "Defender Standards", - "tag": [], + "tag": ["CIS M365 5.0 (2.1.13)"], "helpText": "Sets the anti-spam connection filter policy option 'safe list' in Defender.", "docsDescription": "Sets [Microsoft's built-in 'safe list'](https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps#-enablesafelist) in the anti-spam connection filter policy, rather than setting a custom safe/block list of IPs.", "executiveText": "Enables Microsoft's pre-approved list of trusted email servers to improve email delivery from legitimate sources while maintaining spam protection. This reduces false positives where legitimate emails might be blocked while still protecting against spam and malicious emails.", @@ -1847,7 +1847,6 @@ "label": "Set Anti-Spam Connection Filter Safe List", "impact": "Medium Impact", "impactColour": "info", - "tag": ["CIS M365 5.0 (2.1.13)"], "addedDate": "2025-02-15", "powershellEquivalent": "Set-HostedConnectionFilterPolicy \"Default\" -EnableSafeList $true", "recommendedBy": [] @@ -2095,7 +2094,7 @@ { "name": "standards.DisableResourceMailbox", "cat": "Exchange Standards", - "tag": ["CIS", "NIST CSF 2.0 (PR.AA-01)"], + "tag": ["NIST CSF 2.0 (PR.AA-01)"], "helpText": "Blocks login for all accounts that are marked as a resource mailbox and does not have a license assigned. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", "docsDescription": "Resource mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for resource mailboxes. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", "executiveText": "Prevents direct login to resource mailbox accounts (like conference rooms or equipment), ensuring they can only be managed through proper administrative channels. This security measure eliminates potential unauthorized access to resource scheduling systems while maintaining proper booking functionality.", @@ -2105,7 +2104,7 @@ "impactColour": "warning", "addedDate": "2025-06-01", "powershellEquivalent": "Get-Mailbox & Update-MgUser", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": ["Microsoft", "CIPP"] }, { "name": "standards.EXODisableAutoForwarding", @@ -2283,7 +2282,6 @@ "name": "standards.AntiPhishPolicy", "cat": "Defender Standards", "tag": [ - "CIS", "mdo_safeattachments", "mdo_highconfidencespamaction", "mdo_highconfidencephishaction", @@ -3808,7 +3806,7 @@ { "name": "standards.unmanagedSync", "cat": "SharePoint Standards", - "tag": [], + "tag": ["CIS M365 5.0 (7.2.3)", "CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"], "helpText": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect.", "docsDescription": "Entra P1 required. Block or limit access to SharePoint and OneDrive content from unmanaged devices (those not hybrid AD joined or compliant in Intune). These controls rely on Microsoft Entra Conditional Access policies and can take up to 24 hours to take effect. 0 = Allow Access, 1 = Allow limited, web-only access, 2 = Block access. All information about this can be found in Microsofts documentation [here.](https://learn.microsoft.com/en-us/sharepoint/control-access-from-unmanaged-devices)", "executiveText": "Restricts access to company files from personal or unmanaged devices, ensuring corporate data can only be accessed from properly secured and monitored devices. This critical security control prevents data leaks while allowing controlled access through web browsers when necessary.", @@ -3837,8 +3835,7 @@ "impactColour": "danger", "addedDate": "2025-06-13", "powershellEquivalent": "Set-SPOTenant -ConditionalAccessPolicy AllowFullAccess | AllowLimitedAccess | BlockAccess", - "recommendedBy": ["CIS"], - "tag": ["CIS M365 5.0 (7.2.3)", "CISA (MS.SPO.2.1v1)", "NIST CSF 2.0 (PR.AA-05)"] + "recommendedBy": ["CIS"] }, { "name": "standards.sharingDomainRestriction", diff --git a/src/layouts/config.js b/src/layouts/config.js index e51850cee974..3e00ca524df3 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -264,6 +264,7 @@ export const nativeMenuItems = [ items: [ { title: "Analytics Device Score", path: "/endpoint/reports/analyticsdevicescore" }, { title: "Work from anywhere", path: "/endpoint/reports/workfromanywhere" }, + { title: "Autopilot Deployments", path: "/endpoint/reports/autopilot-deployment" }, ], }, ], diff --git a/src/pages/email/administration/contacts/add.jsx b/src/pages/email/administration/contacts/add.jsx index 3ce17ef0d9d0..c8591f61acca 100644 --- a/src/pages/email/administration/contacts/add.jsx +++ b/src/pages/email/administration/contacts/add.jsx @@ -2,6 +2,9 @@ import { useForm } from "react-hook-form"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; import CippFormPage from "/src/components/CippFormPages/CippFormPage"; import { useSettings } from "../../../../hooks/use-settings"; +import { Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; const AddContact = () => { const tenantDomain = useSettings().currentTenant; diff --git a/src/pages/email/administration/contacts/edit.jsx b/src/pages/email/administration/contacts/edit.jsx index f9eda2ddf3f7..bc8220ef7719 100644 --- a/src/pages/email/administration/contacts/edit.jsx +++ b/src/pages/email/administration/contacts/edit.jsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useCallback } from "react"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; @@ -6,6 +6,9 @@ import CippFormPage from "/src/components/CippFormPages/CippFormPage"; import { useSettings } from "../../../../hooks/use-settings"; import { ApiGetCall } from "../../../../api/ApiCall"; import countryList from "/src/data/countryList.json"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { Divider } from "@mui/material"; const countryLookup = new Map( countryList.map(country => [country.Name, country.Code]) diff --git a/src/pages/email/tools/mailbox-restores/add.jsx b/src/pages/email/tools/mailbox-restores/add.jsx index 984f25a1cc90..75602521ec80 100644 --- a/src/pages/email/tools/mailbox-restores/add.jsx +++ b/src/pages/email/tools/mailbox-restores/add.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect } from "react"; import { Grid } from "@mui/system"; import { useForm, useWatch } from "react-hook-form"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; diff --git a/src/pages/endpoint/applications/list/add.jsx b/src/pages/endpoint/applications/list/add.jsx index 8a8880251b84..f52d33491005 100644 --- a/src/pages/endpoint/applications/list/add.jsx +++ b/src/pages/endpoint/applications/list/add.jsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import { Divider, Button } from "@mui/material"; +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"; @@ -110,9 +110,6 @@ const ApplicationDeploymentForm = () => { validators={{ required: "Please select an application type" }} /> - - - {/* Tenant Selector */} { validators={{ required: "At least one tenant must be selected" }} /> - - + + + { label="Select MSP Tool" name="rmmname" options={[ - { value: "datto", label: "Datto RMM" }, - { value: "syncro", label: "Syncro RMM" }, - { value: "huntress", label: "Huntress" }, - { value: "automate", label: "CW Automate" }, - { value: "cwcommand", label: "CW Command" }, + { value: "datto", label: "Datto RMM", isSponsor: false }, + { value: "syncro", label: "Syncro RMM", isSponsor: true }, + { value: "huntress", label: "Huntress", isSponsor: true }, + { + value: "automate", + label: "CW Automate", + isSponsor: false, + }, + { + value: "cwcommand", + label: "CW Command", + isSponsor: false, + }, ]} formControl={formControl} multiple={false} validators={{ required: "Please select an MSP Tool" }} /> + + + This is a community contribution and is not covered under a vendor sponsorship. Please + join our Discord community for assistance with this MSP App. + + { { title: "Step 2", description: "Device Import", - component: CippWizardCSVImport, + component: CippWizardAutopilotImport, componentProps: { name: "autopilotData", fields: [ diff --git a/src/pages/endpoint/reports/autopilot-deployment/index.js b/src/pages/endpoint/reports/autopilot-deployment/index.js new file mode 100644 index 000000000000..28db44474ea8 --- /dev/null +++ b/src/pages/endpoint/reports/autopilot-deployment/index.js @@ -0,0 +1,112 @@ +import { EyeIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { useSettings } from "/src/hooks/use-settings"; +import { CheckCircle, Error, Warning, Refresh } from "@mui/icons-material"; + +const Page = () => { + const pageTitle = "Autopilot Deployments"; + const tenantFilter = useSettings().currentTenant; + + // Actions for viewing device in Intune and deployment details + const actions = [ + { + label: "View Device in Intune", + link: `https://intune.microsoft.com/${tenantFilter}/#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/overview/mdmDeviceId/[deviceId]`, + color: "info", + icon: , + target: "_blank", + multiPost: false, + external: true, + }, + { + label: "View Deployment Details", + link: `https://intune.microsoft.com/${tenantFilter}/#view/Microsoft_Intune_DeviceSettings/DeploymentOverviewMenuBlade/~/autopilotDeployment/deploymentProfileId/[windowsAutopilotDeploymentProfileDisplayName]`, + color: "info", + icon: , + target: "_blank", + multiPost: false, + external: true, + }, + ]; + + // Extended info fields for the off-canvas panel + const offCanvas = { + extendedInfoFields: [ + "id", + "deviceId", + "userId", + "eventDateTime", + "deviceRegisteredDateTime", + "enrollmentStartDateTime", + "enrollmentType", + "deviceSerialNumber", + "managedDeviceName", + "userPrincipalName", + "windowsAutopilotDeploymentProfileDisplayName", + "enrollmentState", + "windows10EnrollmentCompletionPageConfigurationDisplayName", + "deploymentState", + "deviceSetupStatus", + "accountSetupStatus", + "osVersion", + "deploymentDuration", + "deploymentTotalDuration", + "deviceSetupDuration", + "accountSetupDuration", + "deploymentStartDateTime", + "deploymentEndDateTime", + "enrollmentFailureDetails", + ], + actions: actions, + }; + + // Columns to be displayed in the table (most important first) + const simpleColumns = [ + "managedDeviceName", + "eventDateTime", + "deviceSerialNumber", + "userPrincipalName", + "deploymentState", + "enrollmentState", + "enrollmentType", + "deploymentTotalDuration", + "windowsAutopilotDeploymentProfileDisplayName", + "enrollmentFailureDetails", + ]; + + // Predefined filters for common deployment scenarios + const filterList = [ + { + filterName: "Failed Deployments", + value: [{ id: "deploymentState", value: "failed" }], + type: "column", + }, + { + filterName: "Successful Deployments", + value: [{ id: "deploymentState", value: "success" }], + type: "column", + }, + ]; + + return ( + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; \ No newline at end of file diff --git a/src/pages/identity/administration/users/add.jsx b/src/pages/identity/administration/users/add.jsx index dfbdc7891f03..2d06521fafa0 100644 --- a/src/pages/identity/administration/users/add.jsx +++ b/src/pages/identity/administration/users/add.jsx @@ -56,7 +56,7 @@ const Page = () => { label="Copy properties from another user" multiple={false} select={ - "id,userPrincipalName,displayName,givenName,surname,mailNickname,jobTitle,department,streetAddress,postalCode,companyName,mobilePhone,businessPhones,usageLocation,office" + "id,userPrincipalName,displayName,givenName,surname,mailNickname,jobTitle,department,streetAddress,city,state,postalCode,companyName,mobilePhone,businessPhones,usageLocation,office" } addedField={{ groupType: "calculatedGroupType", @@ -69,6 +69,8 @@ const Page = () => { jobTitle: "jobTitle", department: "department", streetAddress: "streetAddress", + city: "city", + state: "state", postalCode: "postalCode", companyName: "companyName", mobilePhone: "mobilePhone", diff --git a/src/pages/identity/administration/users/user/bec.jsx b/src/pages/identity/administration/users/user/bec.jsx index bc2059b55f80..aa7f3b9d0ba1 100644 --- a/src/pages/identity/administration/users/user/bec.jsx +++ b/src/pages/identity/administration/users/user/bec.jsx @@ -312,7 +312,7 @@ const Page = () => { becPollingCall.data.NewRules.length > 0 && ( - {becPollingCall.data.NewRules.map((rule) => ( + {becPollingCall.data.NewRules.map((rule, index) => ( ))} diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index e40609727478..29ff9301019b 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -16,7 +16,7 @@ import { AlternateEmail, PersonAdd, Block, - PlayArrow + PlayArrow, } from "@mui/icons-material"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; import tabOptions from "./tabOptions"; @@ -100,7 +100,7 @@ const Page = () => { }); const groupsList = ApiGetCall({ - url: "/api/ListGraphRequest", + url: "/api/ListGraphRequest", data: { Endpoint: `groups`, tenantFilter: userSettingsDefaults.currentTenant, @@ -115,27 +115,27 @@ const Page = () => { // Handle undefined/null cases first if (!userIdentifier) { return { - type: 'Unknown', - displayName: 'Unknown User' + type: "Unknown", + displayName: "Unknown User", }; } // Handle special built-in cases - if (userIdentifier === 'Default' || userIdentifier === 'Anonymous') { + if (userIdentifier === "Default" || userIdentifier === "Anonymous") { return { - type: 'System', - displayName: userIdentifier + type: "System", + displayName: userIdentifier, }; } // Check if it's a group - handle Exchange's different naming patterns - const matchingGroup = groupsList?.data?.Results?.find(group => { + const matchingGroup = groupsList?.data?.Results?.find((group) => { // Ensure group properties exist before comparison if (!group) return false; - + return ( // Exact match on mail address - (group.mail && group.mail === userIdentifier) || + (group.mail && group.mail === userIdentifier) || // Exact match on display name (group.displayName && group.displayName === userIdentifier) || // Partial match - permission identifier starts with group display name (handles timestamps) @@ -145,15 +145,15 @@ const Page = () => { if (matchingGroup) { return { - type: 'Group', - displayName: matchingGroup.displayName // Use clean name from Graph API + type: "Group", + displayName: matchingGroup.displayName, // Use clean name from Graph API }; } // If not a system entity or group, assume it's a user return { - type: 'User', - displayName: userIdentifier // Keep original for users + type: "User", + displayName: userIdentifier, // Keep original for users }; }; @@ -281,10 +281,10 @@ const Page = () => { const forwardingAddress = currentSettings.ForwardingAddress; const forwardingSmtpAddress = currentSettings.MailboxActionsData?.ForwardingSmtpAddress; const forwardAndDeliver = currentSettings.ForwardAndDeliver; - + let forwardingType = "disabled"; let cleanAddress = ""; - + if (forwardingSmtpAddress) { // External forwarding forwardingType = "ExternalAddress"; @@ -294,11 +294,11 @@ const Page = () => { forwardingType = "internalAddress"; cleanAddress = forwardingAddress; } - + // Set form values formControl.setValue("forwarding.forwardOption", forwardingType); formControl.setValue("forwarding.KeepCopy", forwardAndDeliver === true); - + if (forwardingType === "internalAddress") { formControl.setValue("forwarding.ForwardInternal", cleanAddress); formControl.setValue("forwarding.ForwardExternal", ""); @@ -314,43 +314,73 @@ const Page = () => { const title = graphUserRequest.isSuccess ? graphUserRequest.data?.[0]?.displayName : "Loading..."; - // Combine users and groups into a single options array - const combinedOptions = useMemo(() => { + // Create options array for mailbox permissions (no system users) + const mailboxPermissionOptions = useMemo(() => { + const options = []; + + // Add users + if (usersList?.data?.Results) { + usersList.data.Results.forEach((user) => { + options.push({ + value: user.userPrincipalName, + label: `${user.displayName} (${user.userPrincipalName})`, + type: "user", + }); + }); + } + + // Add mail-enabled security groups + if (groupsList?.data?.Results) { + groupsList.data.Results.forEach((group) => { + options.push({ + value: group.mail, + label: `${group.displayName} (${group.mail})`, + type: "group", + }); + }); + } + + // Sort alphabetically by label + return options.sort((a, b) => a.label.localeCompare(b.label)); + }, [usersList?.data?.Results, groupsList?.data?.Results]); + + // Create options array for calendar permissions (includes system users) + const calendarPermissionOptions = useMemo(() => { const options = []; - + // Add special system users for calendar permissions options.push({ - value: 'Default', - label: 'Default', - type: 'system' + value: "Default", + label: "Default", + type: "system", }); - + // Add users if (usersList?.data?.Results) { usersList.data.Results.forEach((user) => { options.push({ value: user.userPrincipalName, label: `${user.displayName} (${user.userPrincipalName})`, - type: 'user' + type: "user", }); }); } - + // Add mail-enabled security groups if (groupsList?.data?.Results) { groupsList.data.Results.forEach((group) => { options.push({ value: group.mail, label: `${group.displayName} (${group.mail})`, - type: 'group' + type: "group", }); }); } - + // Sort alphabetically by label, but keep system users at the top return options.sort((a, b) => { - if (a.type === 'system' && b.type !== 'system') return -1; - if (b.type === 'system' && a.type !== 'system') return 1; + if (a.type === "system" && b.type !== "system") return -1; + if (b.type === "system" && a.type !== "system") return 1; return a.label.localeCompare(b.label); }); }, [usersList?.data?.Results, groupsList?.data?.Results]); @@ -409,10 +439,11 @@ const Page = () => { row.forEach((item) => { // Safely extract original user identifier const originalUser = item?._raw?.User || item?.User; - if (originalUser) { // Only add if we have a valid user + if (originalUser) { + // Only add if we have a valid user permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: item?.AccessRights || 'Unknown', + UserID: originalUser, // Use original identifier for API calls + PermissionLevel: item?.AccessRights || "Unknown", Modification: "Remove", }); } @@ -420,10 +451,11 @@ const Page = () => { } else { // Safely extract original user identifier const originalUser = row?._raw?.User || row?.User; - if (originalUser) { // Only add if we have a valid user + if (originalUser) { + // Only add if we have a valid user permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: row?.AccessRights || 'Unknown', + UserID: originalUser, // Use original identifier for API calls + PermissionLevel: row?.AccessRights || "Unknown", Modification: "Remove", }); } @@ -478,7 +510,7 @@ const Page = () => { const userIdentifier = permission?.User; const permissionInfo = getPermissionInfo(permission.User, groupsList); return { - User: permissionInfo.displayName, // Show clean name + User: permissionInfo.displayName, // Show clean name AccessRights: permission.AccessRights, Type: permissionInfo.type, _raw: permission, @@ -507,7 +539,7 @@ const Page = () => { }, { label: "Access Rights", - value: data?.AccessRights || 'Unknown', + value: data?.AccessRights || "Unknown", }, ]} actionItems={mailboxPermissionActions} @@ -558,8 +590,8 @@ const Page = () => { const permissionInfo = getPermissionInfo(permission.User, groupsList); return { User: permissionInfo.displayName, - AccessRights: permission?.AccessRights?.join(", ") || 'Unknown', - FolderName: permission?.FolderName || 'Unknown', + AccessRights: permission?.AccessRights?.join(", ") || "Unknown", + FolderName: permission?.FolderName || "Unknown", Type: permissionInfo.type, _raw: permission, }; @@ -579,7 +611,7 @@ const Page = () => { row.forEach((item) => { const originalUser = item._raw ? item._raw.User : item.User; permissions.push({ - UserID: originalUser, // Use original identifier for API calls + UserID: originalUser, // Use original identifier for API calls PermissionLevel: item.AccessRights, FolderName: item.FolderName, Modification: "Remove", @@ -588,7 +620,7 @@ const Page = () => { } else { const originalUser = row._raw ? row._raw.User : row.User; permissions.push({ - UserID: originalUser, // Use original identifier for API calls + UserID: originalUser, // Use original identifier for API calls PermissionLevel: row.AccessRights, FolderName: row.FolderName, Modification: "Remove", @@ -643,7 +675,7 @@ const Page = () => { tenantFilter: userSettingsDefaults.currentTenant, permissions: [ { - UserID: originalUser, // Use original identifier for API calls + UserID: originalUser, // Use original identifier for API calls PermissionLevel: data.AccessRights, FolderName: data.FolderName, Modification: "Remove", @@ -669,13 +701,15 @@ const Page = () => { type: "POST", icon: , url: "/api/ExecSetMailboxRule", - data: { - ruleId: "Identity", - userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, - ruleName: "Name", - Enable: true, + customDataformatter: (row, action, formData) => { + return { + ruleId: row?.Identity, + userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + ruleName: row?.Name, + Enable: true, + }; }, - condition: (row) => !row.Enabled, + condition: (row) => row && !row.Enabled, confirmText: "Are you sure you want to enable this mailbox rule?", multiPost: false, }, @@ -684,13 +718,15 @@ const Page = () => { type: "POST", icon: , url: "/api/ExecSetMailboxRule", - data: { - ruleId: "Identity", - userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, - ruleName: "Name", - Disable: true, + customDataformatter: (row, action, formData) => { + return { + ruleId: row?.Identity, + userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + ruleName: row?.Name, + Disable: true, + }; }, - condition: (row) => row.Enabled, + condition: (row) => row && row.Enabled, confirmText: "Are you sure you want to disable this mailbox rule?", multiPost: false, }, @@ -699,10 +735,12 @@ const Page = () => { type: "POST", icon: , url: "/api/ExecRemoveMailboxRule", - data: { - ruleId: "Identity", - ruleName: "Name", - userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + customDataformatter: (row, action, formData) => { + return { + ruleId: row?.Identity, + ruleName: row?.Name, + userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + }; }, confirmText: "Are you sure you want to remove this mailbox rule?", multiPost: false, @@ -754,7 +792,50 @@ const Page = () => { cardSx={{ p: 0, m: -2 }} title="Rule Details" propertyItems={properties} - actionItems={mailboxRuleActions} + actionItems={[ + { + label: "Enable Mailbox Rule", + type: "POST", + icon: , + url: "/api/ExecSetMailboxRule", + data: { + ruleId: data?.Identity, + userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + ruleName: data?.Name, + Enable: true, + }, + confirmText: "Are you sure you want to enable this mailbox rule?", + multiPost: false, + }, + { + label: "Disable Mailbox Rule", + type: "POST", + icon: , + url: "/api/ExecSetMailboxRule", + data: { + ruleId: data?.Identity, + userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + ruleName: data?.Name, + Disable: true, + }, + confirmText: "Are you sure you want to disable this mailbox rule?", + multiPost: false, + }, + { + label: "Remove Mailbox Rule", + type: "POST", + icon: , + url: "/api/ExecRemoveMailboxRule", + data: { + ruleId: data?.Identity, + ruleName: data?.Name, + userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + }, + confirmText: "Are you sure you want to remove this mailbox rule?", + multiPost: false, + relatedQueryKeys: `MailboxRules-${userId}`, + }, + ]} /> ); }, @@ -777,7 +858,7 @@ const Page = () => { confirmText: "Are you sure you want to make this the primary proxy address?", multiPost: false, relatedQueryKeys: `ListUsers-${userId}`, - condition: (row) => !row.Type === "Primary", + condition: (row) => row && row.Type !== "Primary", }, { label: "Remove Proxy Address", @@ -792,7 +873,7 @@ const Page = () => { confirmText: "Are you sure you want to remove this proxy address?", multiPost: false, relatedQueryKeys: `ListUsers-${userId}`, - condition: (row) => !row.Type === "Primary", + condition: (row) => row && row.Type !== "Primary", }, ]; @@ -979,17 +1060,13 @@ const Page = () => { api={permissionsApiConfig} row={graphUserRequest.data?.[0]} allowResubmit={true} - defaultvalues={{ - permissions: { - AutoMap: true, - }, - }} > {({ formHook }) => ( )} @@ -1004,7 +1081,7 @@ const Page = () => { {({ formHook }) => ( )} diff --git a/src/pages/teams-share/sharepoint/bulk-add-site.js b/src/pages/teams-share/sharepoint/bulk-add-site.js index a558de5dabca..7c36905ad519 100644 --- a/src/pages/teams-share/sharepoint/bulk-add-site.js +++ b/src/pages/teams-share/sharepoint/bulk-add-site.js @@ -9,10 +9,10 @@ const BulkAddSiteForm = () => { const tenantFilter = useSettings().currentTenant; const fields = [ - "SiteName", + "siteName", "siteDescription", "siteOwner", - "TemplateName", + "templateName", "siteDesign", "sensitivityLabel", ]; diff --git a/src/pages/tenant/standards/compare/index.js b/src/pages/tenant/standards/compare/index.js index 5e4fa556731c..f3f66c2c390f 100644 --- a/src/pages/tenant/standards/compare/index.js +++ b/src/pages/tenant/standards/compare/index.js @@ -39,6 +39,7 @@ import { useDialog } from "../../../../hooks/use-dialog"; import { Grid } from "@mui/system"; import DOMPurify from "dompurify"; import { ClockIcon } from "@heroicons/react/24/outline"; +import ReactMarkdown from "react-markdown"; const Page = () => { const router = useRouter(); @@ -1038,7 +1039,44 @@ const Page = () => { > - {standard.complianceDetails} + theme.palette.primary.main, + textDecoration: "underline", + "&:hover": { + textDecoration: "none", + }, + }, + fontSize: "0.875rem", + lineHeight: 1.43, + "& p": { + my: 0, + }, + flex: 1, + }} + > + ( + + {children} + + ), + // Convert paragraphs to spans to avoid unwanted spacing + p: ({ children }) => {children}, + }} + > + {standard.complianceDetails} + + diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 11987b14bd2d..9f1b07f4769d 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -645,7 +645,17 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr ); } - const durationArray = ["autoExtendDuration"]; + // ISO 8601 Duration Formatting + // Add property names here to automatically format ISO 8601 duration strings (e.g., "PT1H23M30S") + // into human-readable format (e.g., "1 hour 23 minutes 30 seconds") across all CIPP tables. + // This works for any API response property that contains ISO 8601 duration format. + const durationArray = [ + "autoExtendDuration", // GDAP page (/tenant/gdap-management/relationships) + "deploymentDuration", // AutoPilot deployments (/endpoint/reports/autopilot-deployment) + "deploymentTotalDuration", // AutoPilot deployments (/endpoint/reports/autopilot-deployment) + "deviceSetupDuration", // AutoPilot deployments (/endpoint/reports/autopilot-deployment) + "accountSetupDuration" // AutoPilot deployments (/endpoint/reports/autopilot-deployment) + ]; if (durationArray.includes(cellName)) { isoDuration.setLocales( {