diff --git a/.github/workflows/Close_Stale_Issues_and_PRs.yml b/.github/workflows/Close_Stale_Issues_and_PRs.yml index 41dc1be235d7..96b5dc6d5e14 100644 --- a/.github/workflows/Close_Stale_Issues_and_PRs.yml +++ b/.github/workflows/Close_Stale_Issues_and_PRs.yml @@ -13,6 +13,6 @@ jobs: stale-issue-message: 'This issue is stale because it has been open 10 days with no activity. We will close this issue soon. If you want this feature implemented you can contribute it. See: https://docs.cipp.app/dev-documentation/contributing-to-the-code . Please notify the team if you are working on this yourself.' close-issue-message: 'This issue was closed because it has been stalled for 14 days with no activity.' stale-issue-label: 'no-activity' - exempt-issue-labels: 'planned' + exempt-issue-labels: 'planned,bug' days-before-stale: 9 - days-before-close: 14 + days-before-close: 5 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000000..6eaf6dd1ea6f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "streetsidesoftware.code-spell-checker", + ] +} diff --git a/cspell.json b/cspell.json new file mode 100644 index 000000000000..921542d92e48 --- /dev/null +++ b/cspell.json @@ -0,0 +1,31 @@ +{ + "version": "0.2", + "ignorePaths": [], + "dictionaryDefinitions": [], + "dictionaries": [], + "words": [ + "CIPP", + "CIPP-API", + "Entra", + "Intune", + "GDAP", + "OBEE", + "AITM", + "Passwordless", + "Yubikey", + "Sherweb", + "Autotask", + "Datto", + "Syncro", + "ImmyBot", + "Choco", + ], + "ignoreWords": [ + "CIPPAPI", + "locationcipp", + "TNEF", + "winmail", + "PSTN", + ], + "import": [] +} diff --git a/public/version.json b/public/version.json index e13bd0cafa30..a687242f92c7 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "7.4.2" + "version": "7.5.0" } diff --git a/src/components/CippCards/CippInfoBar.jsx b/src/components/CippCards/CippInfoBar.jsx index c7ce557fdfbe..bcdf4930475f 100644 --- a/src/components/CippCards/CippInfoBar.jsx +++ b/src/components/CippCards/CippInfoBar.jsx @@ -1,58 +1,102 @@ +import React, { useState } from "react"; import { Box, Card, Stack, SvgIcon, Typography, Skeleton } from "@mui/material"; import Grid from "@mui/material/Grid"; +import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; +import { CippPropertyListCard } from "./CippPropertyListCard"; -export const CippInfoBar = ({ data, isFetching }) => ( - - - {data.map((item) => ( - ({ - xs: `1px solid ${theme.palette.divider}`, - md: "none", - }), - borderRight: (theme) => ({ - md: `1px solid ${theme.palette.divider}`, - }), - "&:nth-of-type(3)": { - borderBottom: (theme) => ({ - xs: `1px solid ${theme.palette.divider}`, - sm: "none", - }), - }, - "&:nth-of-type(4)": { - borderBottom: "none", - borderRight: "none", - }, - }} - > - - {item?.icon && ( - - {item.icon} - - )} - { - if (!item?.icon) { - return { pl: 2 }; - } +export const CippInfoBar = ({ data, isFetching }) => { + const [visibleIndex, setVisibleIndex] = useState(null); + + return ( + + + {data.map((item, index) => ( + <> + setVisibleIndex(index) : undefined} + sx={{ + cursor: item.offcanvas ? "pointer" : "default", + borderBottom: (theme) => ({ + xs: `1px solid ${theme.palette.divider}`, + md: "none", + }), + borderRight: (theme) => ({ + md: `1px solid ${theme.palette.divider}`, + }), + "&:nth-of-type(3)": { + borderBottom: (theme) => ({ + xs: `1px solid ${theme.palette.divider}`, + sm: "none", + }), + }, + "&:nth-of-type(4)": { + borderBottom: "none", + borderRight: "none", + }, }} > - - {item.name} - - - {isFetching ? : item.data} - - - - - ))} - - -); + + {item?.icon && ( + + {item.icon} + + )} + { + if (!item?.icon) { + return { pl: 2 }; + } + }} + > + + {item.name} + + + {isFetching ? : item.data} + + + + + {item.offcanvas && ( + <> + {console.log("item.offcanvas", item.offcanvas)} + setVisibleIndex(null)} + > + + + + {item?.offcanvas?.propertyItems?.length > 0 && ( + + )} + + + + + > + )} + > + ))} + + + ); +}; diff --git a/src/components/CippComponents/BPASyncDialog.jsx b/src/components/CippComponents/BPASyncDialog.jsx new file mode 100644 index 000000000000..cba62df8e67c --- /dev/null +++ b/src/components/CippComponents/BPASyncDialog.jsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogTitle, + Button, + DialogActions, + Alert, + CircularProgress, +} from "@mui/material"; +import { CheckCircle, Error, Sync } from "@mui/icons-material"; +import { useForm, FormProvider } from "react-hook-form"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { ApiPostCall } from "/src/api/ApiCall"; +import { CippApiResults } from "./CippApiResults"; + +export const BPASyncDialog = ({ createDialog }) => { + const methods = useForm({ + defaultValues: { + tenantFilter: { + value: "AllTenants", + label: "*All Tenants", + }, + }, + }); + + // Use methods for form handling and control + const { handleSubmit, control } = methods; + + const [tenantId, setTenantId] = useState(""); + const [isSyncing, setIsSyncing] = useState(false); + + // Use ApiGetCall instead of useApiCall + const bpaSyncResults = ApiPostCall({ + urlfromdata: true, + }); + + const handleForm = (values) => { + setTenantId(values.tenantFilter || ""); + setIsSyncing(true); + + bpaSyncResults.mutate({ + url: "/api/ExecBPA", + queryKey: `bpa-sync-${tenantId}`, + data: tenantId ? { TenantFilter: tenantId } : {}, + }); + }; + + // Reset syncing state when dialog is closed + const handleClose = () => { + setIsSyncing(false); + createDialog.handleClose(); + }; + + return ( + + + + Force BPA Sync + + + + This will force a Best Practice Analyzer (BPA) sync. Select a tenant (or all + tenants) below. + + + + + + + Cancel + } + > + Sync BPA + + + + + + ); +}; diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index 865e28e38d6c..d083a3c9d2bf 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -3,7 +3,7 @@ import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid } from import { Stack } from "@mui/system"; import { CippApiResults } from "./CippApiResults"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { useSettings } from "../../hooks/use-settings"; import CippFormComponent from "./CippFormComponent"; @@ -284,6 +284,10 @@ export const CippApiDialog = (props) => { const [linkClicked, setLinkClicked] = useState(false); + useEffect(() => { + setLinkClicked(false); + }, [api.link]); + useEffect(() => { if (api.link && !linkClicked && row && Object.keys(row).length > 0) { const timeoutId = setTimeout(() => { @@ -319,18 +323,40 @@ export const CippApiDialog = (props) => { }; var confirmText; - if (typeof api?.confirmText === "string" && !Array.isArray(row)) { - confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, (_, key) => { - return getNestedValue(row, key) || `[${key}]`; - }); - } else if (Array.isArray(row) && row.length > 1) { - confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, "the selected rows"); - } else if (Array.isArray(row) && row.length === 1) { - confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, (_, key) => { - return getNestedValue(row[0], key) || `[${key}]`; - }); + if (typeof api?.confirmText === "string") { + if (!Array.isArray(row)) { + confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, (_, key) => { + return getNestedValue(row, key) || `[${key}]`; + }); + } else if (row.length > 1) { + confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, "the selected rows"); + } else if (row.length === 1) { + confirmText = api.confirmText.replace(/\[([^\]]+)\]/g, (_, key) => { + return getNestedValue(row[0], key) || `[${key}]`; + }); + } } else { - confirmText = api.confirmText; + // Handle JSX/Component confirmText + const replaceTextInElement = (element) => { + if (!element) return element; + if (typeof element === "string") { + if (Array.isArray(row) && row.length > 1) { + return element.replace(/\[([^\]]+)\]/g, "the selected rows"); + } else if (Array.isArray(row) && row.length === 1) { + return element.replace( + /\[([^\]]+)\]/g, + (_, key) => getNestedValue(row[0], key) || `[${key}]` + ); + } + return element.replace(/\[([^\]]+)\]/g, (_, key) => getNestedValue(row, key) || `[${key}]`); + } + if (React.isValidElement(element)) { + const newChildren = React.Children.map(element.props.children, replaceTextInElement); + return React.cloneElement(element, {}, newChildren); + } + return element; + }; + confirmText = replaceTextInElement(api?.confirmText); } return ( diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index b7bcc0a675be..21917507c396 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -1,4 +1,4 @@ -import { Close, Download, RouterOutlined } from "@mui/icons-material"; +import { Close, Download } from "@mui/icons-material"; import { Alert, CircularProgress, diff --git a/src/components/CippComponents/CippExchangeActions.jsx b/src/components/CippComponents/CippExchangeActions.jsx index 667b5b6fd686..0cce26aec516 100644 --- a/src/components/CippComponents/CippExchangeActions.jsx +++ b/src/components/CippComponents/CippExchangeActions.jsx @@ -15,8 +15,8 @@ import { Key, PostAdd, Add, + Gavel, } from "@mui/icons-material"; -import { useSettings } from "/src/hooks/use-settings.js"; export const CippExchangeActions = () => { // const tenant = useSettings().currentTenant; @@ -167,6 +167,28 @@ export const CippExchangeActions = () => { condition: (row) => row.MessageCopyForSentAsEnabled === true && row.recipientTypeDetails === "SharedMailbox", }, + { + label: "Set Litigation Hold", + type: "POST", + url: "/api/ExecSetLitigationHold", + data: { UPN: "UPN", Identity: "Id" }, + confirmText: "What do you want to set the Litigation Hold to?", + icon: , + condition: (row) => row.LicensedForLitigationHold === true, + fields: [ + { + type: "switch", + name: "disable", + label: "Disable Litigation Hold", + }, + { + type: "number", + name: "days", + label: "Hold Duration (Days)", + placeholder: "Blank or 0 for indefinite", + }, + ], + }, { label: "Set mailbox locale", type: "POST", diff --git a/src/components/CippComponents/CippGdapActions.jsx b/src/components/CippComponents/CippGdapActions.jsx index d2ef3a41e531..b2200cd64b7b 100644 --- a/src/components/CippComponents/CippGdapActions.jsx +++ b/src/components/CippComponents/CippGdapActions.jsx @@ -82,7 +82,7 @@ export const CippGdapActions = () => [ confirmText: ( <> - Are you sure you want to reset the role mappings for this relationship? + Are you sure you want to reset the role mappings for [customer.displayName]? Resetting GDAP role mappings will perform the following actions: diff --git a/src/components/CippComponents/ScheduledTaskDetails.jsx b/src/components/CippComponents/ScheduledTaskDetails.jsx new file mode 100644 index 000000000000..88aabea0f512 --- /dev/null +++ b/src/components/CippComponents/ScheduledTaskDetails.jsx @@ -0,0 +1,268 @@ +import React, { useEffect, useState } from "react"; +import { + Box, + Typography, + IconButton, + Accordion, + AccordionSummary, + AccordionDetails, + Chip, + TextField, + InputAdornment, + Tooltip, + Stack, + Skeleton, +} from "@mui/material"; +import { ApiGetCall } from "../../api/ApiCall"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; +import { ExpandMore, Sync, Search, Close } from "@mui/icons-material"; +import { getCippFormatting } from "../../utils/get-cipp-formatting"; +import { CippDataTable } from "../CippTable/CippDataTable"; +import { CippTimeAgo } from "/src/components/CippComponents/CippTimeAgo"; + +const ScheduledTaskDetails = ({ data }) => { + const [taskDetails, setTaskDetails] = useState(null); + const [expanded, setExpanded] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + const handleChange = (panel) => (event, newExpanded) => { + setExpanded(newExpanded ? panel : false); + }; + + const taskDetailResults = ApiGetCall({ + url: `/api/ListScheduledItemDetails`, + data: { + RowKey: data.RowKey, + }, + queryKey: `ListScheduledItemDetails-${data.RowKey}`, + }); + + const taskProperties = [ + "TaskState", + "Command", + "Tenant", + "Recurrence", + "ScheduledTime", + "ExecutedTime", + "PostExecution", + ]; + + useEffect(() => { + if (taskDetailResults.isSuccess && taskDetailResults?.data) { + setTaskDetails(taskDetailResults.data); + + // Auto-expand the only result if there's just one + if (taskDetailResults.data.Details?.length === 1) { + setExpanded(`execution-results-0`); + } + } + }, [data.RowKey, taskDetailResults.isSuccess, taskDetailResults.data]); + + const filteredDetails = taskDetails?.Details?.filter((result) => { + if (!searchQuery) return true; + + const searchLower = searchQuery.toLowerCase(); + const tenantMatches = (result.TenantName || result.Tenant || "") + .toLowerCase() + .includes(searchLower); + + let resultsMatches = false; + if (typeof result.Results === "object" && result.Results !== null) { + const resultsStr = JSON.stringify(result.Results).toLowerCase(); + resultsMatches = resultsStr.includes(searchLower); + } + + return tenantMatches || resultsMatches; + }); + + return ( + <> + + {taskDetails?.Task?.Name} + + taskDetailResults.refetch()}> + + + + } + layout="dual" + title="Details" + variant="outlined" + showDivider={false} + propertyItems={taskProperties + .filter((prop) => taskDetails?.Task?.[prop] != null && taskDetails?.Task?.[prop] !== "") + .map((prop) => { + return { + label: getCippTranslation(prop), + value: getCippFormatting(taskDetails?.Task?.[prop], prop), + }; + })} + isFetching={taskDetailResults.isFetching} + /> + + {taskDetailResults.isFetching ? ( + + ) : ( + <> + {taskDetails?.Task?.Parameters && ( + + }> + Task Parameters + + + { + return { + label: key, + value: getCippFormatting(value, key), + }; + } + )} + isFetching={taskDetailResults.isFetching} + /> + + + )} + > + )} + + {taskDetailResults.isFetching ? ( + + ) : ( + <> + {taskDetails?.Details?.length > 0 && ( + <> + + + Execution Results{" "} + {filteredDetails && ( + + ({filteredDetails.length} of {taskDetails.Details.length}) + + )} + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchQuery && ( + + + setSearchQuery("")} + aria-label="Clear search" + > + + + + + ), + }} + /> + + + {filteredDetails && + filteredDetails.map((result, index) => ( + + } + sx={{ + "& .MuiAccordionSummary-content": { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + width: "100%", + }, + }} + > + {result.TenantName || result.Tenant} + } + sx={{ mx: 1 }} + /> + + + {result.Results === "null" ? ( + No data available + ) : Array.isArray(result.Results) ? ( + + ) : typeof result.Results === "object" ? ( + ({ + label: key, + value: typeof value === "object" ? JSON.stringify(value) : value, + }))} + /> + ) : ( + + + {result.Results} + + + )} + + + ))} + {filteredDetails && filteredDetails.length === 0 && ( + + + No results match your search criteria + + + )} + + > + )} + > + )} + + > + ); +}; + +export default ScheduledTaskDetails; diff --git a/src/components/CippFormPages/CippSchedulerForm.jsx b/src/components/CippFormPages/CippSchedulerForm.jsx index 325fd788757e..d49b2f069254 100644 --- a/src/components/CippFormPages/CippSchedulerForm.jsx +++ b/src/components/CippFormPages/CippSchedulerForm.jsx @@ -51,7 +51,7 @@ const CippSchedulerForm = (props) => { }; const recurrenceOptions = [ - { value: "0", label: "Only once" }, + { value: "0", label: "Once" }, { value: "1d", label: "Every 1 day" }, { value: "7d", label: "Every 7 days" }, { value: "30d", label: "Every 30 days" }, @@ -69,8 +69,8 @@ const CippSchedulerForm = (props) => { }); const tenantList = ApiGetCall({ - url: "/api/ListTenants", - queryKey: "ListTenants", + url: "/api/ListTenants?AllTenantSelector=true", + queryKey: "ListTenants-AllTenants", }); useEffect(() => { if (scheduledTaskList.isSuccess && router.query.id) { @@ -86,16 +86,27 @@ const CippSchedulerForm = (props) => { ); if (commands.isSuccess) { const command = commands.data.find((command) => command.Function === task.Command); + var recurrence = recurrenceOptions.find( + (option) => option.value === task.Recurrence || option.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); + } else if (typeof task.ScheduledTime === "string") { + task.ScheduledTime = Math.floor(new Date(task.ScheduledTime).getTime() / 1000); + } + const ResetParams = { tenantFilter: { value: tenantFilter?.defaultDomainName, - label: tenantFilter?.defaultDomainName, + label: `${tenantFilter?.displayName} (${tenantFilter?.defaultDomainName})`, }, 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 }, ScheduledTime: task.ScheduledTime, - Recurrence: task.Recurrence, + Recurrence: recurrence, parameters: task.Parameters, postExecution: postExecution, advancedParameters: task.RawJsonParameters ? true : false, diff --git a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx index 53ed74c6e822..d495bc370e6c 100644 --- a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx @@ -197,6 +197,7 @@ const CippIntegrationSettings = ({ children }) => { creatable={false} multiple={false} isFetching={mappings.isFetching} + sortOptions={true} /> diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index fdb538a4dec0..8037de3b2e2b 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -12,8 +12,20 @@ import { Grid, Tooltip, Chip, + TextField, + InputAdornment, + ButtonGroup, + Button, } from "@mui/material"; -import { ExpandMore as ExpandMoreIcon, Delete, Add, Public } from "@mui/icons-material"; +import { + ExpandMore as ExpandMoreIcon, + Delete, + Add, + Public, + Search, + Close, + FilterAlt, +} from "@mui/icons-material"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; import { useWatch } from "react-hook-form"; import _ from "lodash"; @@ -24,6 +36,7 @@ import Defender from "../../icons/iconly/bulk/defender"; import Intune from "../../icons/iconly/bulk/intune"; import GDAPRoles from "/src/data/GDAPRoles"; import timezoneList from "/src/data/timezoneList"; +import standards from "/src/data/standards.json"; const getAvailableActions = (disabledFeatures) => { const allActions = [ @@ -69,7 +82,7 @@ const CippAddedComponent = React.memo(({ standardName, component, formControl }) CippAddedComponent.displayName = "CippAddedComponent"; const CippStandardAccordion = ({ - standards, + standards: providedStandards, selectedStandards, expanded, handleAccordionToggle, @@ -78,6 +91,8 @@ const CippStandardAccordion = ({ formControl, }) => { const [configuredState, setConfiguredState] = useState({}); + const [filter, setFilter] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); const watchedValues = useWatch({ control: formControl.control, @@ -87,7 +102,7 @@ const CippStandardAccordion = ({ const newConfiguredState = { ...configuredState }; Object.keys(selectedStandards).forEach((standardName) => { - const standard = standards.find((s) => s.name === standardName.split("[")[0]); + const standard = providedStandards.find((s) => s.name === standardName.split("[")[0]); if (standard) { const actionFilled = !!_.get(watchedValues, `${standardName}.action`, false); @@ -100,7 +115,6 @@ const CippStandardAccordion = ({ const isConfigured = actionFilled && addedComponentsFilled; - // Only update state if there's a change to reduce unnecessary re-renders. if (newConfiguredState[standardName] !== isConfigured) { newConfiguredState[standardName] = isConfigured; } @@ -110,148 +124,328 @@ const CippStandardAccordion = ({ if (!_.isEqual(newConfiguredState, configuredState)) { setConfiguredState(newConfiguredState); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [watchedValues, standards, selectedStandards]); - - return Object.keys(selectedStandards)?.map((standardName) => { - const standard = standards.find((s) => s.name === standardName.split("[")[0]); - if (!standard) return null; - - const isExpanded = expanded === standardName; - const hasAddedComponents = standard.addedComponent && standard.addedComponent.length > 0; - const isConfigured = configuredState[standardName]; - const disabledFeatures = standard.disabledFeatures || {}; - - let selectedActions = _.get(watchedValues, `${standardName}.action`); - //if selectedActions is not an array, convert it to an array - if (selectedActions && !Array.isArray(selectedActions)) { - selectedActions = [selectedActions]; + }, [watchedValues, providedStandards, selectedStandards]); + + const groupedStandards = useMemo(() => { + const result = {}; + + Object.keys(selectedStandards).forEach((standardName) => { + const baseStandardName = standardName.split("[")[0]; + const standard = providedStandards.find((s) => s.name === baseStandardName); + if (!standard) return; + + const standardInfo = standards.find((s) => s.name === baseStandardName); + const category = standardInfo?.cat || "Other Standards"; + + if (!result[category]) { + result[category] = []; + } + + result[category].push({ + standardName, + standard, + }); + }); + + Object.keys(result).forEach((category) => { + result[category].sort((a, b) => a.standard.label.localeCompare(b.standard.label)); + }); + + return result; + }, [selectedStandards, providedStandards]); + + const filteredGroupedStandards = useMemo(() => { + if (!searchQuery && filter === "all") { + return groupedStandards; } - const selectedTemplateName = standard.multiple - ? _.get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) - : ""; - const accordionTitle = selectedTemplateName - ? `${standard.label} - ${selectedTemplateName.label}` - : standard.label; - - return ( - - - - - {standard.cat === "Global Standards" ? ( - - ) : standard.cat === "Entra (AAD) Standards" ? ( - - ) : standard.cat === "Exchange Standards" ? ( - - ) : standard.cat === "Defender Standards" ? ( - - ) : standard.cat === "Intune Standards" ? ( - - ) : ( - - )} - - - {accordionTitle} - {selectedActions && selectedActions?.length > 0 && ( - - {selectedActions?.map((action, index) => ( - <> - - > - ))} - - - )} - { - //add a chip that shows the impact - } - - {standard.helpText} - - - - - {standard.multiple && ( - - handleAddMultipleStandard(standardName)}> - - - - )} - - {isConfigured ? "Configured" : "Unconfigured"} - handleRemoveStandard(standardName)}> - - - - handleAccordionToggle(standardName)}> - { + const categoryMatchesSearch = !searchQuery || category.toLowerCase().includes(searchLower); + + const filteredStandards = groupedStandards[category].filter(({ standardName, standard }) => { + const matchesSearch = + !searchQuery || + categoryMatchesSearch || + standard.label.toLowerCase().includes(searchLower) || + (standard.helpText && standard.helpText.toLowerCase().includes(searchLower)) || + (standard.cat && standard.cat.toLowerCase().includes(searchLower)) || + (standard.tag && + Array.isArray(standard.tag) && + standard.tag.some((tag) => tag.toLowerCase().includes(searchLower))); + + const isConfigured = configuredState[standardName]; + const matchesFilter = + filter === "all" || + (filter === "configured" && isConfigured) || + (filter === "unconfigured" && !isConfigured); + + return matchesSearch && matchesFilter; + }); + + if (filteredStandards.length > 0) { + result[category] = filteredStandards; + } + }); + + return result; + }, [groupedStandards, searchQuery, filter, configuredState]); + + const standardCounts = useMemo(() => { + let allCount = 0; + let configuredCount = 0; + let unconfiguredCount = 0; + + Object.keys(groupedStandards).forEach((category) => { + groupedStandards[category].forEach(({ standardName }) => { + allCount++; + if (configuredState[standardName]) { + configuredCount++; + } else { + unconfiguredCount++; + } + }); + }); + + return { allCount, configuredCount, unconfiguredCount }; + }, [groupedStandards, configuredState]); + + const hasFilteredStandards = Object.keys(filteredGroupedStandards).length > 0; + + return ( + <> + {Object.keys(selectedStandards).length > 0 && ( + <> + + + setSearchQuery(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: searchQuery && ( + + + setSearchQuery("")} + aria-label="Clear search" + > + + + + + ), + }, + }} /> - + + + + + + + + setFilter("all")} + > + All ({standardCounts.allCount}) + + setFilter("configured")} + > + Configured ({standardCounts.configuredCount}) + + setFilter("unconfigured")} + > + Unconfigured ({standardCounts.unconfiguredCount}) + + - - - - - - - - - - - {hasAddedComponents && ( - - - {standard.addedComponent?.map((component, idx) => ( - + + No standards match the selected filter criteria or search query. + + + )} + > + )} + + {Object.keys(filteredGroupedStandards).map((category) => ( + + + {category} + + + {filteredGroupedStandards[category].map(({ standardName, standard }) => { + const isExpanded = expanded === standardName; + const hasAddedComponents = + standard.addedComponent && standard.addedComponent.length > 0; + const isConfigured = configuredState[standardName]; + const disabledFeatures = standard.disabledFeatures || {}; + + let selectedActions = _.get(watchedValues, `${standardName}.action`); + if (selectedActions && !Array.isArray(selectedActions)) { + selectedActions = [selectedActions]; + } + + const selectedTemplateName = standard.multiple + ? _.get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) + : ""; + const accordionTitle = selectedTemplateName + ? `${standard.label} - ${selectedTemplateName.label}` + : standard.label; + + return ( + + + + + {standard.cat === "Global Standards" ? ( + + ) : standard.cat === "Entra (AAD) Standards" ? ( + + ) : standard.cat === "Exchange Standards" ? ( + + ) : standard.cat === "Defender Standards" ? ( + + ) : standard.cat === "Intune Standards" ? ( + + ) : ( + + )} + + + {accordionTitle} + {selectedActions && selectedActions?.length > 0 && ( + + {selectedActions?.map((action, index) => ( + + + + ))} + + + )} + + {standard.helpText} + + + + + {standard.multiple && ( + + handleAddMultipleStandard(standardName)}> + + + + )} + + + {isConfigured ? "Configured" : "Unconfigured"} + + handleRemoveStandard(standardName)}> + + + + handleAccordionToggle(standardName)}> + - ))} - - - )} - - - - - ); - }); + + + + + + + + + + + + + {hasAddedComponents && ( + + + {standard.addedComponent?.map((component, idx) => ( + + ))} + + + )} + + + + + ); + })} + + ))} + > + ); }; export default CippStandardAccordion; diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 9237ab580ad1..25beed16fb5d 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -429,6 +429,7 @@ export const CippDataTable = (props) => { api={actionData.action} row={actionData.data} relatedQueryKeys={queryKey ? queryKey : title} + {...actionData.action} /> ); }, [actionData.ready, createDialog, actionData.action, actionData.data, queryKey, title])} diff --git a/src/components/csvExportButton.js b/src/components/csvExportButton.js index c09474b0472c..5e7424dad1c8 100644 --- a/src/components/csvExportButton.js +++ b/src/components/csvExportButton.js @@ -8,32 +8,66 @@ const csvConfig = mkConfig({ useKeysAsHeaders: true, }); +const flattenObject = (obj, parentKey = "") => { + const flattened = {}; + Object.keys(obj).forEach((key) => { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + if (typeof obj[key] === "object" && obj[key] !== null && !Array.isArray(obj[key])) { + Object.assign(flattened, flattenObject(obj[key], fullKey)); + } else if (Array.isArray(obj[key])) { + // Handle arrays of objects by applying the formatter on each property + flattened[fullKey] = obj[key] + .map((item) => + typeof item === "object" + ? JSON.stringify( + Object.fromEntries( + Object.entries(flattenObject(item)).map(([k, v]) => [k, getCippFormatting(v, k, "text", false)]) + ) + ) + : getCippFormatting(item, fullKey, "text", false) + ) + .join(", "); + } else { + flattened[fullKey] = obj[key]; + } + }); + return flattened; +}; + export const CSVExportButton = (props) => { const { rows, columns, reportName, columnVisibility, ...other } = props; const handleExportRows = (rows) => { - const rowData = rows.map((row) => row.original); + const rowData = rows.map((row) => flattenObject(row.original)); const columnKeys = columns.filter((c) => columnVisibility[c.id]).map((c) => c.id); - rowData.forEach((row) => { - Object.keys(row).forEach((key) => { - if (!columnKeys.includes(key)) { - delete row[key]; + + const filterRowData = (row, allowedKeys) => { + const filteredRow = {}; + allowedKeys.forEach((key) => { + if (key in row) { + filteredRow[key] = row[key]; } }); - }); + return filteredRow; + }; - //for every existing row, get the valid formatting using getCippFormatting. - const formattedData = rowData.map((row) => { + const filteredData = rowData.map((row) => filterRowData(row, columnKeys)); + + const formattedData = filteredData.map((row) => { const formattedRow = {}; - Object.keys(row).forEach((key) => { - formattedRow[key] = getCippFormatting(row[key], key, "text", false); + columnKeys.forEach((key) => { + const value = row[key]; + // Pass flattened data to the formatter for CSV export + formattedRow[key] = getCippFormatting(value, key, "text", false); }); return formattedRow; }); + const csv = generateCsv(csvConfig)(formattedData); csvConfig["filename"] = `${reportName}`; download(csvConfig)(csv); }; + return ( diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 4be4aec14662..3c61cbbba574 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -95,6 +95,108 @@ "compareValue": true, "action": "disable" } + }, + { + "type": "switch", + "name": "Sherweb.AutoMigrations", + "label": "Enable automated migration to Sherweb", + "condition": { + "field": "Sherweb.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, + { + "type": "autoComplete", + "name": "Sherweb.migrationMethods", + "label": "Select how you'd like automated migrations to be handled", + "options": [ + { + "label": "Notify only - This will notify you when a subscription is in its cancellation window for non Sherweb subscriptions", + "value": "notifyOnly" + }, + { + "label": "Buy and notify - This will automatically buy the subscription and notify you when a subscription is in its cancellation window for non Sherweb subscriptions", + "value": "buyAndNotify" + }, + { + "label": "Buy and cancel - This will automatically buy the subscription and cancel the old subscription when a subscription is in its cancellation window for non Sherweb subscriptions", + "value": "buyAndCancel" + } + ], + "multiple": false, + "condition": { + "field": "Sherweb.AutoMigrations", + "compareType": "is", + "compareValue": true + } + }, + { + "type": "autoComplete", + "name": "Sherweb.migrateFrom", + "label": "Select the vendor to automatically migrate from", + "options": [ + { + "label": "Pax8", + "value": "Pax8" + } + ], + "multiple": false, + "condition": { + "field": "Sherweb.migrationMethods", + "compareType": "is", + "compareValue": "buyAndCancel" + } + }, + { + "type": "autoComplete", + "name": "Sherweb.migrateToLicense", + "label": "Select the type of license to automatically migrate to", + "options": [ + { + "label": "Yearly", + "value": "Y1Y" + }, + { + "label": "Annual paid monthly", + "value": "M1Y" + }, + { + "label": "Monthly", + "value": "M2M" + } + ], + "multiple": false, + "condition": { + "field": "Sherweb.migrationMethods", + "compareType": "contains", + "compareValue": "buy" + } + }, + { + "type": "password", + "name": "Pax8.clientId", + "label": "Pax8 Client ID", + "placeholder": "Enter your Pax8 Client ID", + "required": true, + "condition": { + "field": "Sherweb.migrateFrom", + "compareType": "is", + "compareValue": "Pax8" + } + }, + { + "type": "password", + "name": "Pax8.APIKey", + "label": "Pax8 Client Secret", + "placeholder": "Enter your Pax Client Secret", + "required": true, + "condition": { + "field": "Sherweb.migrateFrom", + "compareType": "is", + "compareValue": "Pax8" + } } ] }, diff --git a/src/data/standards.json b/src/data/standards.json index 71d668bf8bca..92ef2e2806f5 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -217,7 +217,7 @@ "name": "standards.DisableBasicAuthSMTP", "cat": "Global Standards", "tag": [], - "helpText": "Disables SMTP AUTH for the organization and all users. This is the default for new tenants. ", + "helpText": "Disables SMTP AUTH for the organization and all users. This is the default for new tenants.", "docsDescription": "Disables SMTP basic authentication for the tenant and all users with it explicitly enabled.", "addedComponent": [], "label": "Disable SMTP Basic Authentication", @@ -546,7 +546,7 @@ "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", "tag": ["CIS"], - "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles. ", + "helpText": "Restricts creation of M365 tenants to the Global Administrator or Tenant Creator roles.", "docsDescription": "Users by default are allowed to create M365 tenants. This disables that so only admins can create new M365 tenants.", "addedComponent": [], "label": "Disable M365 Tenant creation by users", @@ -772,7 +772,7 @@ "impact": "Medium Impact", "impactColour": "warning", "addedDate": "2024-11-12", - "powershellEquivalent": "", + "powershellEquivalent": "Graph API", "recommendedBy": [] }, { @@ -785,7 +785,7 @@ { "type": "number", "name": "standards.StaleEntraDevices.deviceAgeThreshold", - "label": "Days before stale(Dont set below 30)" + "label": "Days before stale(Do not set below 30)" } ], "disabledFeatures": { @@ -1687,11 +1687,46 @@ "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", "recommendedBy": [] }, + { + "name": "standards.SharePointMassDeletionAlert", + "cat": "Defender Standards", + "tag": [], + "helpText": "Sets a e-mail address to alert when a User deletes more than 20 SharePoint files within 60 minutes. NB: Requires a Office 365 E5 subscription, Office 365 E3 with Threat Intelligence or Office 365 EquivioAnalytics add-on.", + "docsDescription": "Sets a e-mail address to alert when a User deletes more than 20 SharePoint files within 60 minutes. This is useful for monitoring and ensuring that the correct SharePoint files are deleted. NB: Requires a Office 365 E5 subscription, Office 365 E3 with Threat Intelligence or Office 365 EquivioAnalytics add-on.", + "addedComponent": [ + { + "type": "number", + "name": "standards.SharePointMassDeletionAlert.Threshold", + "label": "Max files to delete within the time frame", + "defaultValue": 20 + }, + { + "type": "number", + "name": "standards.SharePointMassDeletionAlert.TimeWindow", + "label": "Time frame in minutes", + "defaultValue": 60 + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": true, + "name": "standards.SharePointMassDeletionAlert.NotifyUser", + "label": "E-mail to receive the alert" + } + ], + "label": "SharePoint Mass Deletion Alert", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-04-07", + "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", + "recommendedBy": [] + }, { "name": "standards.SafeLinksPolicy", "cat": "Defender Standards", "tag": ["CIS", "mdo_safelinksforemail", "mdo_safelinksforOfficeApps"], - "helpText": "This creates a safelink policy that automatically scans, tracks, and and enables safe links for Email, Office, and Teams for both external and internal senders", + "helpText": "This creates a Safe Links policy that automatically scans, tracks, and and enables safe links for Email, Office, and Teams for both external and internal senders", "addedComponent": [ { "type": "switch", @@ -1717,7 +1752,7 @@ "label": "Do not rewrite the following URLs in email" } ], - "label": "Default SafeLinks Policy", + "label": "Default Safe Links Policy", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-03-25", @@ -1737,7 +1772,7 @@ "mdo_antiphishingpolicies", "mdo_phishthresholdlevel" ], - "helpText": "This creates a Anti-Phishing policy that automatically enables Mailbox Intelligence and spoofing, optional switches for Mailtips.", + "helpText": "This creates a Anti-Phishing policy that automatically enables Mailbox Intelligence and spoofing, optional switches for Mail tips.", "addedComponent": [ { "type": "number", @@ -1930,7 +1965,7 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-03-25", - "powershellEquivalent": "Set-AntiphishPolicy or New-AntiphishPolicy", + "powershellEquivalent": "Set-AntiPhishPolicy or New-AntiPhishPolicy", "recommendedBy": ["CIS"] }, { @@ -2019,6 +2054,44 @@ "powershellEquivalent": "Set-AtpPolicyForO365", "recommendedBy": ["CIS"] }, + { + "name": "standards.PhishingSimulations", + "cat": "Defender Standards", + "tag": [], + "helpText": "This creates a phishing simulation policy that enables phishing simulations for the entire tenant.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": true, + "label": "Phishing Simulation Domains", + "name": "standards.PhishingSimulations.Domains" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": true, + "label": "Phishing Simulation Sender IP Ranges", + "name": "standards.PhishingSimulations.SenderIpRanges" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "label": "Phishing Simulation Urls", + "name": "standards.PhishingSimulations.PhishingSimUrls" + } + ], + "label": "Phishing Simulation Configuration", + "impact": "Medium Impact", + "impactColour": "info", + "addedDate": "2025-03-27", + "powershellEquivalent": "New-TenantAllowBlockListItems, New-PhishSimOverridePolicy and New-ExoPhishSimOverrideRule", + "recommendedBy": [] + }, { "name": "standards.MalwareFilterPolicy", "cat": "Defender Standards", @@ -2099,6 +2172,28 @@ "powershellEquivalent": "Set-MalwareFilterPolicy or New-MalwareFilterPolicy", "recommendedBy": ["CIS"] }, + { + "name": "standards.PhishSimSpoofIntelligence", + "cat": "Defender Standards", + "tag": [], + "helpText": "This adds allowed domains to the Spoof Intelligence Allow/Block List.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "label": "Allowed Domains", + "name": "standards.PhishSimSpoofIntelligence.AllowedDomains" + } + ], + "label": "Add allowed domains to Spoof Intelligence", + "impact": "Medium Impact", + "impactColour": "info", + "addedDate": "2025-03-28", + "powershellEquivalent": "New-TenantAllowBlockListSpoofItems", + "recommendedBy": [] + }, { "name": "standards.SpamFilterPolicy", "cat": "Defender Standards", @@ -2511,7 +2606,7 @@ "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-11-12", - "powershellEquivalent": "", + "powershellEquivalent": "Graph API", "recommendedBy": [] }, { @@ -2545,6 +2640,80 @@ "powershellEquivalent": "Graph API", "recommendedBy": [] }, + { + "name": "standards.DefaultPlatformRestrictions", + "cat": "Intune Standards", + "tag": [], + "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.platformAndroidForWorkBlocked", + "label": "Block platform Android Enterprise (work profile)", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.personalAndroidForWorkBlocked", + "label": "Block personally owned Android Enterprise (work profile)", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.platformAndroidBlocked", + "label": "Block platform Android", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.personalAndroidBlocked", + "label": "Block personally owned Android", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.platformiOSBlocked", + "label": "Block platform iOS", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.personaliOSBlocked", + "label": "Block personally owned iOS", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.platformMacOSBlocked", + "label": "Block platform macOS", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.personalMacOSBlocked", + "label": "Block personally owned macOS", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.platformWindowsBlocked", + "label": "Block platform Windows", + "default": false + }, + { + "type": "switch", + "name": "standards.DefaultPlatformRestrictions.personalWindowsBlocked", + "label": "Block personally owned Windows", + "default": false + } + ], + "label": "Device enrollment restrictions", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-04-01", + "powershellEquivalent": "Graph API", + "recommendedBy": [] + }, { "name": "standards.intuneDeviceReg", "cat": "Intune Standards", @@ -2570,7 +2739,7 @@ "cat": "Intune Standards", "tag": [], "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", - "label": "Require Multifactor Authentication to register or join devices with Microsoft Entra", + "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", "impact": "Medium Impact", "impactColour": "warning", "addedDate": "2023-10-23", @@ -2678,7 +2847,7 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EnableAzureADB2BIntegration $true", - "recommendedBy": ["CIS 3.0"] + "recommendedBy": ["CIS"] }, { "name": "standards.SPDisallowInfectedFiles", @@ -2736,7 +2905,7 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -ExternalUserExpireInDays 30 -ExternalUserExpirationRequired $True", - "recommendedBy": ["CIS 3.0"] + "recommendedBy": ["CIS"] }, { "name": "standards.SPEmailAttestation", @@ -2878,7 +3047,7 @@ "helpText": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access", "docsDescription": "Disables the ability for external users to share files they don't own. Sharing links can only be made for People with existing access. This is a tenant wide setting and overrules any settings set on the site level", "addedComponent": [], - "label": "Disable Resharing by External Users", + "label": "Disable Re-sharing by External Users", "impact": "High Impact", "impactColour": "danger", "addedDate": "2022-06-15", @@ -3054,7 +3223,7 @@ "impactColour": "info", "addedDate": "2024-11-12", "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers EveryoneInCompanyExcludingGuests -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false", - "recommendedBy": ["CIS 3.0"] + "recommendedBy": ["CIS"] }, { "name": "standards.TeamsEmailIntegration", @@ -3074,7 +3243,7 @@ "impactColour": "info", "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowEmailIntoChannel $false", - "recommendedBy": ["CIS 3.0"] + "recommendedBy": ["CIS"] }, { "name": "standards.TeamsExternalFileSharing", @@ -3113,7 +3282,7 @@ "impactColour": "info", "addedDate": "2024-07-28", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGoogleDrive $false -AllowShareFile $false -AllowBox $false -AllowDropBox $false -AllowEgnyte $false", - "recommendedBy": ["CIS 3.0"] + "recommendedBy": ["CIS"] }, { "name": "standards.TeamsEnrollUser", diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 0cebc1ac3acd..f41183ba0f11 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -5,6 +5,10 @@ import Bars3Icon from "@heroicons/react/24/outline/Bars3Icon"; import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; import SunIcon from "@heroicons/react/24/outline/SunIcon"; import BookmarkIcon from "@mui/icons-material/Bookmark"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { Box, Divider, @@ -27,6 +31,8 @@ import { NotificationsPopover } from "./notifications-popover"; import { useDialog } from "../hooks/use-dialog"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { CippCentralSearch } from "../components/CippComponents/CippCentralSearch"; +import { applySort } from "../utils/apply-sort"; + const TOP_NAV_HEIGHT = 64; export const TopNav = (props) => { @@ -42,6 +48,7 @@ export const TopNav = (props) => { }, [settings]); const [anchorEl, setAnchorEl] = useState(null); + const [sortOrder, setSortOrder] = useState("asc"); const handleBookmarkClick = (event) => { setAnchorEl(event.currentTarget); @@ -51,8 +58,42 @@ export const TopNav = (props) => { setAnchorEl(null); }; - const open = Boolean(anchorEl); - const id = open ? "bookmark-popover" : undefined; + const handleSortToggle = () => { + const newSortOrder = sortOrder === "asc" ? "desc" : "asc"; + setSortOrder(newSortOrder); + + // Save the new sort order and re-order bookmarks + const sortedBookmarks = applySort(settings.bookmarks || [], "label", newSortOrder); + settings.handleUpdate({ + bookmarks: sortedBookmarks, + sortOrder: newSortOrder, + }); + }; + + // Move a bookmark up in the list + const moveBookmarkUp = (index) => { + if (index <= 0) return; + + const updatedBookmarks = [...(settings.bookmarks || [])]; + const temp = updatedBookmarks[index]; + updatedBookmarks[index] = updatedBookmarks[index - 1]; + updatedBookmarks[index - 1] = temp; + + settings.handleUpdate({ bookmarks: updatedBookmarks }); + }; + + // Move a bookmark down in the list + const moveBookmarkDown = (index) => { + const bookmarks = settings.bookmarks || []; + if (index >= bookmarks.length - 1) return; + + const updatedBookmarks = [...bookmarks]; + const temp = updatedBookmarks[index]; + updatedBookmarks[index] = updatedBookmarks[index + 1]; + updatedBookmarks[index + 1] = temp; + + settings.handleUpdate({ bookmarks: updatedBookmarks }); + }; useEffect(() => { const handleKeyDown = (event) => { @@ -67,10 +108,22 @@ export const TopNav = (props) => { }; }, []); + useEffect(() => { + if (settings.sortOrder) { + setSortOrder(settings.sortOrder); + } + }, [settings.sortOrder]); + const openSearch = () => { searchDialog.handleOpen(); }; + // Use the sorted bookmarks if sorting is applied, otherwise use the bookmarks in their current order + const displayBookmarks = settings.bookmarks || []; + + const open = Boolean(anchorEl); + const id = open ? "bookmark-popover" : undefined; + return ( { horizontal: "center", }} > - - {(settings.bookmarks || []).length === 0 ? ( + + + + + {sortOrder === "asc" ? : } + + + Sort Alphabetically + + {displayBookmarks.length === 0 ? ( No bookmarks added yet - } + primary={No bookmarks added yet} /> ) : ( - settings.bookmarks.map((bookmark, idx) => ( + displayBookmarks.map((bookmark, idx) => ( handleBookmarkClose()} + sx={{ + color: "inherit", + display: "flex", + justifyContent: "space-between", + }} > - {bookmark.label} - } - /> + handleBookmarkClose()} + sx={{ + textDecoration: "none", + color: "inherit", + flexGrow: 1, + marginRight: 2, + }} + > + {bookmark.label} + + + { + e.preventDefault(); + moveBookmarkUp(idx); + }} + disabled={idx === 0} + > + + + { + e.preventDefault(); + moveBookmarkDown(idx); + }} + disabled={idx === displayBookmarks.length - 1} + > + + + )) )} diff --git a/src/pages/cipp/scheduler/index.js b/src/pages/cipp/scheduler/index.js index 81cf3213ed02..6bd64ff2d6fd 100644 --- a/src/pages/cipp/scheduler/index.js +++ b/src/pages/cipp/scheduler/index.js @@ -1,14 +1,28 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import CippTablePage from "/src/components/CippComponents/CippTablePage"; -import { Button, Typography } from "@mui/material"; +import { Button } from "@mui/material"; import Link from "next/link"; import { CalendarDaysIcon, EyeIcon, TrashIcon } from "@heroicons/react/24/outline"; import { useState } from "react"; -import { CopyAll, Edit } from "@mui/icons-material"; -import { CippCodeBlock } from "../../../components/CippComponents/CippCodeBlock"; +import { CopyAll, Edit, PlayArrow } from "@mui/icons-material"; +import ScheduledTaskDetails from "../../../components/CippComponents/ScheduledTaskDetails"; const Page = () => { const actions = [ + { + label: "View Task Details", + link: "/cipp/scheduler/task?id=[RowKey]", + icon: , + }, + { + label: "Run Now", + type: "POST", + url: "/api/AddScheduledItem", + data: { RowKey: "RowKey", RunNow: true }, + icon: , + confirmText: "Are you sure you want to run [Name]?", + allowResubmit: true, + }, { label: "Edit Job", link: "/cipp/scheduler/job?id=[RowKey]", @@ -58,12 +72,8 @@ const Page = () => { ]; const offCanvas = { - children: (extendedData) => ( - <> - Job Results - - > - ), + children: (extendedData) => , + size: "xl", actions: actions, }; const [showHiddenJobs, setShowHiddenJobs] = useState(false); diff --git a/src/pages/cipp/scheduler/task.js b/src/pages/cipp/scheduler/task.js new file mode 100644 index 000000000000..a41c47ee55bf --- /dev/null +++ b/src/pages/cipp/scheduler/task.js @@ -0,0 +1,22 @@ +import { useRouter } from "next/router"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import ScheduledTaskDetails from "../../../components/CippComponents/ScheduledTaskDetails"; +import CippPageCard from "../../../components/CippCards/CippPageCard"; +import { CardContent, CardHeader } from "@mui/material"; + +const Page = () => { + const router = useRouter(); + const { id } = router.query; + + return ( + + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/email/administration/mailbox-rules/index.js b/src/pages/email/administration/mailbox-rules/index.js index e25ba44d8ae6..8dc0fcceb020 100644 --- a/src/pages/email/administration/mailbox-rules/index.js +++ b/src/pages/email/administration/mailbox-rules/index.js @@ -4,14 +4,45 @@ import { TrashIcon } from "@heroicons/react/24/outline"; import { getCippTranslation } from "../../../../utils/get-cipp-translation"; import { getCippFormatting } from "../../../../utils/get-cipp-formatting"; import { CippPropertyListCard } from "../../../../components/CippCards/CippPropertyListCard"; +import { Block, PlayArrow, DeleteForever } from "@mui/icons-material"; const Page = () => { const pageTitle = "Mailbox Rules"; const actions = [ + { + label: "Enable Mailbox Rule", + type: "POST", + icon: , + url: "/api/ExecSetMailboxRule", + data: { + ruleId: "Identity", + userPrincipalName: "UserPrincipalName", + ruleName: "Name", + Enable: true, + }, + condition: (row) => !row.Enabled, + 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: "Identity", + userPrincipalName: "UserPrincipalName", + ruleName: "Name", + Disable: true, + }, + condition: (row) => row.Enabled, + confirmText: "Are you sure you want to disable this mailbox rule?", + multiPost: false, + }, { label: "Remove Mailbox Rule", type: "POST", - icon: , + icon: , url: "/api/ExecRemoveMailboxRule", data: { ruleId: "Identity", userPrincipalName: "UserPrincipalName", ruleName: "Name" }, confirmText: "Are you sure you want to remove this mailbox rule?", diff --git a/src/pages/email/administration/quarantine/index.js b/src/pages/email/administration/quarantine/index.js index 9ed7c94d2160..fc0323c4f77e 100644 --- a/src/pages/email/administration/quarantine/index.js +++ b/src/pages/email/administration/quarantine/index.js @@ -104,6 +104,7 @@ const Page = () => { label: "Release", type: "POST", url: "/api/ExecQuarantineManagement", + multiPost: true, data: { Identity: "Identity", Type: "!Release", diff --git a/src/pages/email/tools/message-trace/index.js b/src/pages/email/tools/message-trace/index.js index 1cde2ba78cba..9a856cefb8af 100644 --- a/src/pages/email/tools/message-trace/index.js +++ b/src/pages/email/tools/message-trace/index.js @@ -83,6 +83,12 @@ const Page = () => { }, icon: , }, + { + label: "View in Explorer", + noConfirm: true, + link: `https://security.microsoft.com/realtimereportsv3?tid=${tenantFilter}&dltarget=Explorer&dlstorage=Url&viewid=allemail&query-NetworkMessageId=[MessageTraceId]`, + icon: , + }, ]; const onSubmit = () => { diff --git a/src/pages/endpoint/autopilot/list-devices/index.js b/src/pages/endpoint/autopilot/list-devices/index.js index 038e5b9d8d2a..4f641664f439 100644 --- a/src/pages/endpoint/autopilot/list-devices/index.js +++ b/src/pages/endpoint/autopilot/list-devices/index.js @@ -2,7 +2,7 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; import { CippApiDialog } from "/src/components/CippComponents/CippApiDialog.jsx"; import { Button } from "@mui/material"; -import { PersonAdd, Delete, Sync, Add } from "@mui/icons-material"; +import { PersonAdd, Delete, Sync, Add, Edit, Sell } from "@mui/icons-material"; import { useDialog } from "../../../../hooks/use-dialog"; import Link from "next/link"; import { useState } from "react"; @@ -42,6 +42,69 @@ const Page = () => { ], color: "info", }, + { + label: "Rename Device", + icon: , + type: "POST", + url: "/api/ExecRenameAPDevice", + data: { + deviceId: "id", + serialNumber: "serialNumber", + }, + confirmText: "Enter the new display name for the device.", + fields: [ + { + type: "textField", + name: "displayName", + label: "New Display Name", + required: true, + validate: (value) => { + if (!value) { + return "Display name is required."; + } + if (value.length > 15) { + return "Display name must be 15 characters or less."; + } + if (/\s/.test(value)) { + return "Display name cannot contain spaces."; + } + if (!/^[a-zA-Z0-9-]+$/.test(value)) { + return "Display name can only contain letters, numbers, and hyphens."; + } + if (/^[0-9]+$/.test(value)) { + return "Display name cannot contain only numbers."; + } + return true; // Indicates validation passed + }, + }, + ], + color: "secondary", + }, + { + label: "Edit Group Tag", + icon: , + type: "POST", + url: "/api/ExecSetAPDeviceGroupTag", + data: { + deviceId: "id", + serialNumber: "serialNumber", + }, + confirmText: "Enter the new group tag for the device.", + fields: [ + { + type: "textField", + name: "groupTag", + label: "Group Tag", + validate: (value) => { + if (value && value.length > 128) { + return "Group tag cannot exceed 128 characters."; + } + return true; // Validation passed + }, + }, + ], + color: "secondary", + }, { label: "Delete Device", icon: , diff --git a/src/pages/identity/administration/groups/index.js b/src/pages/identity/administration/groups/index.js index ce0fd6b290f8..427a2ae002a5 100644 --- a/src/pages/identity/administration/groups/index.js +++ b/src/pages/identity/administration/groups/index.js @@ -86,7 +86,7 @@ const Page = () => { url: "/api/AddGroupTemplate", icon: , data: { - Displayname: "displayname", + Displayname: "displayName", Description: "description", GroupType: "calculatedGroupType", MembershipRules: "membershipRule", diff --git a/src/pages/identity/administration/users/user/bec.jsx b/src/pages/identity/administration/users/user/bec.jsx index ed7c586489ec..9e8c1a59eb9e 100644 --- a/src/pages/identity/administration/users/user/bec.jsx +++ b/src/pages/identity/administration/users/user/bec.jsx @@ -58,7 +58,11 @@ const Page = () => { // Fetch BEC Check result using GUID const becPollingCall = ApiGetCall({ - url: `/api/execBECCheck?GUID=${becInitialCall.data?.GUID}`, + url: `/api/execBECCheck`, + data: { + GUID: becInitialCall.data?.GUID, + tenantFilter: userSettingsDefaults.currentTenant, + }, queryKey: `execBECCheck-polling-${becInitialCall.data?.GUID}`, waiting: false, }); diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 136636349783..9d3c263f3b95 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -18,7 +18,7 @@ import CippExchangeSettingsForm from "../../../../../components/CippFormPages/Ci import { useForm } from "react-hook-form"; import { Alert, Button, Collapse, CircularProgress, Typography } from "@mui/material"; import { CippApiResults } from "../../../../../components/CippComponents/CippApiResults"; -import { TrashIcon } from "@heroicons/react/24/outline"; +import { Block, PlayArrow, DeleteForever } from "@mui/icons-material"; import { CippPropertyListCard } from "../../../../../components/CippCards/CippPropertyListCard"; import { getCippTranslation } from "../../../../../utils/get-cipp-translation"; import { getCippFormatting } from "../../../../../utils/get-cipp-formatting"; @@ -188,10 +188,40 @@ const Page = () => { ]; const mailboxRuleActions = [ + { + label: "Enable Mailbox Rule", + type: "POST", + icon: , + url: "/api/ExecSetMailboxRule", + data: { + ruleId: "Identity", + userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + ruleName: "Name", + Enable: true, + }, + condition: (row) => !row.Enabled, + 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: "Identity", + userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, + ruleName: "Name", + Disable: true, + }, + condition: (row) => row.Enabled, + confirmText: "Are you sure you want to disable this mailbox rule?", + multiPost: false, + }, { label: "Remove Mailbox Rule", type: "POST", - icon: , + icon: , url: "/api/ExecRemoveMailboxRule", data: { ruleId: "Identity", diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index ba6943f77d9c..53475d4b31c7 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -81,22 +81,43 @@ const AlertWizard = () => { const alert = existingAlert?.data?.find((alert) => alert.RowKey === router.query.id); if (alert?.LogType === "Scripted") { setAlertType("script"); - formControl.setValue("tenantFilter", { - value: alert.RawAlert.Tenant, - label: alert.RawAlert.Tenant, - }); + + console.log(alert); + + // Create formatted excluded tenants array if it exists + const excludedTenantsFormatted = Array.isArray(alert.excludedTenants) + ? alert.excludedTenants.map((tenant) => ({ value: tenant, label: tenant })) + : []; + + // Format the command object const usedCommand = alertList?.find( (cmd) => cmd.name === alert.RawAlert.Command.replace("Get-CIPPAlert", "") ); - formControl.setValue("command", { value: usedCommand, label: usedCommand.label }); - formControl.setValue( - "recurrence", - recurrenceOptions?.find((opt) => opt.value === alert.RawAlert.Recurrence) + + // Format recurrence option + const recurrenceOption = recurrenceOptions?.find( + (opt) => opt.value === alert.RawAlert.Recurrence ); + + // Format post execution values const postExecutionValue = postExecutionOptions.filter((opt) => alert.RawAlert.PostExecution.split(",").includes(opt.value) ); - formControl.setValue("postExecution", postExecutionValue); + + // Reset the form with all values at once + formControl.reset( + { + tenantFilter: { + value: alert.RawAlert.Tenant, + label: alert.RawAlert.Tenant, + }, + excludedTenants: excludedTenantsFormatted, + command: { value: usedCommand, label: usedCommand.label }, + recurrence: recurrenceOption, + postExecution: postExecutionValue, + }, + { keepDirty: false } + ); } if (alert?.PartitionKey === "Webhookv2") { setAlertType("audit"); @@ -113,6 +134,7 @@ const AlertWizard = () => { formControl.reset({ RowKey: router.query.clone ? undefined : router.query.id ? router.query.id : undefined, tenantFilter: alert.RawAlert.Tenants, + excludedTenants: alert.RawAlert.excludedTenants, Actions: alert.RawAlert.Actions, conditions: alert.RawAlert.Conditions, logbook: foundLogbook, diff --git a/src/pages/tenant/conditional/deploy-vacation/index.js b/src/pages/tenant/conditional/deploy-vacation/index.js index ca4092689195..e1c24e2a761c 100644 --- a/src/pages/tenant/conditional/deploy-vacation/index.js +++ b/src/pages/tenant/conditional/deploy-vacation/index.js @@ -3,8 +3,22 @@ import CippTablePage from "/src/components/CippComponents/CippTablePage"; import { Button } from "@mui/material"; import { EventAvailable } from "@mui/icons-material"; import Link from "next/link"; +import { Delete } from "@mui/icons-material"; const Page = () => { + const actions = [ + { + label: "Cancel Vacation Mode", + type: "POST", + url: "/api/RemoveScheduledItem", + data: { ID: "RowKey" }, + confirmText: + "Are you sure you want to cancel this vacation mode entry? This might mean the user will remain in vacation mode permanently.", + icon: , + multiPost: false, + }, + ]; + return ( { apiUrl="/api/ListScheduledItems?Type=Set-CIPPCAExclusion" queryKey="VacationMode" tenantInTitle={false} + actions={actions} simpleColumns={[ "Name", "TaskState", @@ -26,6 +41,18 @@ const Page = () => { "Parameters.UserName", "Parameters.PolicyId", ]} + offCanvas={{ + extendedInfoFields: [ + "Name", + "TaskState", + "ScheduledTime", + "Parameters.UserName", + "Parameters.PolicyId", + "Tenant", + "ExecutedTime", + ], + actions: actions, + }} /> ); }; diff --git a/src/pages/tenant/gdap-management/offboarding.js b/src/pages/tenant/gdap-management/offboarding.js index 57a6d31f381b..bba83bba45aa 100644 --- a/src/pages/tenant/gdap-management/offboarding.js +++ b/src/pages/tenant/gdap-management/offboarding.js @@ -137,6 +137,15 @@ const Page = () => { (relationship) => relationship?.customer?.tenantId === tenantId.value )?.length ?? 0, icon: , + offcanvas: { + title: "GDAP Relationships", + propertyItems: gdapRelationships.data?.Results + ?.filter((relationship) => relationship?.customer?.tenantId === tenantId.value) + ?.map((relationship) => ({ + label: `Relationship: ${relationship?.displayName}`, + value: `Id: ${relationship?.id}`, + })), + }, }, { name: "CSP Contract", @@ -152,11 +161,27 @@ const Page = () => { name: "MSP Applications", data: mspApps.data?.Results?.length ?? 0, icon: , + offcanvas: { + title: "MSP Applications", + propertyItems: mspApps.data?.Results?.map((app) => ({ + label: app?.displayName, + value: app?.appId, + })), + }, }, { name: "Vendor Applications", - data: 0, + data: vendorApps.data?.pages?.reduce((sum, page) => sum + (page?.Results?.length ?? 0), 0) ?? 0, icon: , + offcanvas: { + title: "Vendor Applications", + propertyItems: vendorApps.data?.pages + ?.reduce((sum, page) => sum.concat(page?.Results ?? []), []) + .map((app) => ({ + label: app?.displayName, + value: app?.appId, + })), + } }, ]} /> diff --git a/src/pages/tenant/standards/bpa-report/index.js b/src/pages/tenant/standards/bpa-report/index.js index 5307dfa9a934..1c98aad23b69 100644 --- a/src/pages/tenant/standards/bpa-report/index.js +++ b/src/pages/tenant/standards/bpa-report/index.js @@ -3,17 +3,22 @@ import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx" import { Layout as DashboardLayout } from "/src/layouts/index.js"; // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative. import Link from "next/link"; import { EyeIcon } from "@heroicons/react/24/outline"; -import { CopyAll, Delete, Edit, AddBox, GitHub } from "@mui/icons-material"; +import { CopyAll, Delete, Edit, AddBox, GitHub, Sync } from "@mui/icons-material"; import { ApiGetCall } from "/src/api/ApiCall"; +import { Stack } from "@mui/system"; +import { BPASyncDialog } from "/src/components/CippComponents/BPASyncDialog"; +import { useDialog } from "/src/hooks/use-dialog"; const Page = () => { const pageTitle = "Best Practice Reports"; + const bpaDialog = useDialog(); const integrations = ApiGetCall({ url: "/api/ListExtensionsConfig", queryKey: "Integrations", refetchOnMount: false, refetchOnReconnect: false, }); + const actions = [ { label: "View Report", @@ -95,18 +100,26 @@ const Page = () => { ]; return ( - }> - Add Template - - } - actions={actions} - simpleColumns={["Name", "Style"]} - queryKey="ListBPATemplates" - /> + <> + + }> + Add Template + + }> + Force Sync + + + } + actions={actions} + simpleColumns={["Name", "Style"]} + queryKey="ListBPATemplates" + /> + + > ); }; diff --git a/src/pages/tenant/standards/compare/index.js b/src/pages/tenant/standards/compare/index.js index 7b96932616fe..59f092bd81ab 100644 --- a/src/pages/tenant/standards/compare/index.js +++ b/src/pages/tenant/standards/compare/index.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Button, Card, @@ -172,6 +172,13 @@ const Page = () => { const standardInfo = standards.find((s) => s.name === standardId); const standardSettings = standardConfig.standards?.[standardKey] || {}; console.log(standardInfo); + + // Check if reporting is enabled for this standard by checking the action property + // The standard should be reportable if there's an action with value === 'Report' + const actions = standardConfig.action || []; + const reportingEnabled = + actions.filter((action) => action.value === "Report").length > 0; + // Find the tenant's value for this standard const currentTenantStandard = currentTenantData.find( (s) => s.standardId === standardId @@ -179,6 +186,7 @@ const Page = () => { // Determine compliance status let isCompliant = false; + let reportingDisabled = !reportingEnabled; // Check if the standard is directly in the tenant object (like "standards.AuditLog": true) const standardIdWithoutPrefix = standardId.replace("standards.", ""); @@ -203,6 +211,13 @@ const Page = () => { } } + // Determine compliance status text based on reporting flag + const complianceStatus = reportingDisabled + ? "Reporting Disabled" + : isCompliant + ? "Compliant" + : "Non-Compliant"; + // Use the direct standard value from the tenant object if it exists allStandards.push({ standardId, @@ -212,7 +227,8 @@ const Page = () => { ? directStandardValue : currentTenantStandard?.value, standardValue: standardSettings, - complianceStatus: isCompliant ? "Compliant" : "Non-Compliant", + complianceStatus, + reportingDisabled, complianceDetails: standardInfo?.docsDescription || standardInfo?.helpText || "", standardDescription: standardInfo?.helpText || "", standardImpact: standardInfo?.impact || "Medium Impact", @@ -241,26 +257,82 @@ const Page = () => { ]); const comparisonModeOptions = [{ label: "Compare Tenant to Standard", value: "standard" }]; - const filteredData = comparisonData?.filter((standard) => { - const matchesFilter = - filter === "all" || - (filter === "compliant" && standard.complianceStatus === "Compliant") || - (filter === "nonCompliant" && standard.complianceStatus === "Non-Compliant"); + // Group standards by category + const groupedStandards = useMemo(() => { + if (!comparisonData) return {}; - const matchesSearch = - !searchQuery || - standard.standardName.toLowerCase().includes(searchQuery.toLowerCase()) || - standard.standardDescription.toLowerCase().includes(searchQuery.toLowerCase()); + const result = {}; - return matchesFilter && matchesSearch; - }); + comparisonData.forEach((standard) => { + // Find the standard info in the standards.json data + const standardInfo = standards.find((s) => s.name === standard.standardId); + + // Use the category from standards.json, or default to "Other Standards" + const category = standardInfo?.cat || "Other Standards"; + + if (!result[category]) { + result[category] = []; + } + + result[category].push(standard); + }); + + // Sort standards within each category + Object.keys(result).forEach((category) => { + result[category].sort((a, b) => a.standardName.localeCompare(b.standardName)); + }); + + return result; + }, [comparisonData]); + + const filteredGroupedStandards = useMemo(() => { + if (!groupedStandards) return {}; + + if (!searchQuery && filter === "all") { + return groupedStandards; + } + + const result = {}; + const searchLower = searchQuery.toLowerCase(); + + Object.keys(groupedStandards).forEach((category) => { + const categoryMatchesSearch = !searchQuery || category.toLowerCase().includes(searchLower); + + const filteredStandards = groupedStandards[category].filter((standard) => { + const matchesFilter = + filter === "all" || + (filter === "compliant" && standard.complianceStatus === "Compliant") || + (filter === "nonCompliant" && standard.complianceStatus === "Non-Compliant"); + + const matchesSearch = + !searchQuery || + categoryMatchesSearch || + standard.standardName.toLowerCase().includes(searchLower) || + standard.standardDescription.toLowerCase().includes(searchLower); + + return matchesFilter && matchesSearch; + }); + + if (filteredStandards.length > 0) { + result[category] = filteredStandards; + } + }); + + return result; + }, [groupedStandards, searchQuery, filter]); const allCount = comparisonData?.length || 0; const compliantCount = comparisonData?.filter((standard) => standard.complianceStatus === "Compliant").length || 0; const nonCompliantCount = comparisonData?.filter((standard) => standard.complianceStatus === "Non-Compliant").length || 0; - const compliancePercentage = allCount > 0 ? Math.round((compliantCount / allCount) * 100) : 0; + const reportingDisabledCount = + comparisonData?.filter((standard) => standard.complianceStatus === "Reporting Disabled") + .length || 0; + const compliancePercentage = + allCount > 0 + ? Math.round((compliantCount / (allCount - reportingDisabledCount || 1)) * 100) + : 0; return ( @@ -549,7 +621,7 @@ const Page = () => { )} - {filteredData && filteredData.length === 0 && ( + {filteredGroupedStandards && Object.keys(filteredGroupedStandards).length === 0 && ( No standards match the selected filter criteria or search query. @@ -559,89 +631,277 @@ const Page = () => { )} - {filteredData && - filteredData.length > 0 && - filteredData.map((standard, index) => ( - - - - + + {Object.keys(filteredGroupedStandards).map((category) => ( + + + {category} + + + {filteredGroupedStandards[category].map((standard, index) => ( + + + - - - {standard.complianceStatus === "Compliant" ? ( - - ) : ( - - )} - - - {standard?.standardName} - - + + + + {standard.complianceStatus === "Compliant" ? ( + + ) : standard.complianceStatus === "Reporting Disabled" ? ( + + ) : ( + + )} + + {standard?.standardName} + + + + - - - - {!standard.standardValue ? ( - - This data has not yet been collected. Collect the data by pressing the - report button on the top of the page. - - ) : ( - + + + {!standard.standardValue ? ( + + This data has not yet been collected. Collect the data by pressing the + report button on the top of the page. + + ) : ( + + + {standard.standardValue && + typeof standard.standardValue === "object" && + Object.keys(standard.standardValue).length > 0 ? ( + Object.entries(standard.standardValue).map(([key, value]) => ( + + + {key}: + + + {typeof value === "object" && value !== null + ? value.label || JSON.stringify(value) + : value === true + ? "Enabled" + : value === false + ? "Disabled" + : String(value)} + + + )) + ) : ( + + {standard.standardValue === true ? ( + + This setting is configured correctly + + ) : standard.standardValue === false ? ( + + This setting is not configured correctly + + ) : standard.standardValue !== undefined ? ( + typeof standard.standardValue === "object" ? ( + "No settings configured" + ) : ( + String(standard.standardValue) + ) + ) : ( + + This setting is not configured, or data has not been + collected. If you are getting this after data collection, the + tenant might not be licensed for this feature + + )} + + )} + + + + )} + + + + + + + + + + + + + - {standard.standardValue && - typeof standard.standardValue === "object" && - Object.keys(standard.standardValue).length > 0 ? ( - Object.entries(standard.standardValue).map(([key, value]) => ( + + + + {currentTenant} + + + + + + + + + {standard.complianceStatus} + + + + + + + {/* Existing tenant comparison content */} + {typeof standard.currentTenantValue === "object" && + standard.currentTenantValue !== null ? ( + + {standard.complianceStatus === "Reporting Disabled" ? ( + + Reporting is disabled for this standard in the template configuration. + + ) : ( + Object.entries(standard.currentTenantValue).map(([key, value]) => { + const standardValueForKey = + standard.standardValue && typeof standard.standardValue === "object" + ? standard.standardValue[key] + : undefined; + + const isDifferent = + standardValueForKey !== undefined && + JSON.stringify(value) !== JSON.stringify(standardValueForKey); + + return ( {key}: - - {typeof value === "object" && value !== null + + {standard.complianceStatus === "Compliant" && value === true + ? "Compliant" + : typeof value === "object" && value !== null ? value.label || JSON.stringify(value) : value === true ? "Enabled" @@ -650,235 +910,76 @@ const Page = () => { : String(value)} - )) - ) : ( - - {standard.standardValue === true ? ( - - This setting is configured correctly - - ) : standard.standardValue === false ? ( - - This setting is not configured correctly - - ) : standard.standardValue !== undefined ? ( - typeof standard.standardValue === "object" ? ( - "No settings configured" - ) : ( - String(standard.standardValue) - ) - ) : ( - - This setting is not configured, or data has not been collected. - If you are getting this after data collection, the tenant might - not be licensed for this feature - - )} - - )} - + ); + }) + )} - - )} - - - + ) : ( + + {standard.complianceStatus === "Reporting Disabled" ? ( + + Reporting is disabled for this standard in the template configuration. + + ) : standard.complianceStatus === "Compliant" && + standard.currentTenantValue === true ? ( + + This setting is configured correctly + + ) : standard.currentTenantValue === false ? ( + + This setting is not configured correctly + + ) : standard.currentTenantValue !== undefined ? ( + String(standard.currentTenantValue) + ) : ( + + This setting is not configured, or data has not been collected. If you + are getting this after data collection, the tenant might not be + licensed for this feature + + )} + + )} - - - + + - - - - - + {standard.complianceDetails && ( + + + - + - - {currentTenant} - - - - + {standard.complianceDetails} - - - - {standard.complianceStatus} - - - - - - - {typeof standard.currentTenantValue === "object" && - standard.currentTenantValue !== null ? ( - - {Object.entries(standard.currentTenantValue).map(([key, value]) => { - const standardValueForKey = - standard.standardValue && typeof standard.standardValue === "object" - ? standard.standardValue[key] - : undefined; - - const isDifferent = - standardValueForKey !== undefined && - JSON.stringify(value) !== JSON.stringify(standardValueForKey); - - return ( - - - {key}: - - - {standard.complianceStatus === "Compliant" && value === true - ? "Compliant" - : typeof value === "object" && value !== null - ? value.label || JSON.stringify(value) - : value === true - ? "Enabled" - : value === false - ? "Disabled" - : String(value)} - - - ); - })} - - ) : ( - - {standard.complianceStatus === "Compliant" && - standard.currentTenantValue === true ? ( - - This setting is configured correctly - - ) : standard.currentTenantValue === false ? ( - - This setting is not configured correctly - - ) : standard.currentTenantValue !== undefined ? ( - String(standard.currentTenantValue) - ) : ( - - This setting is not configured, or data has not been collected. If you - are getting this after data collection, the tenant might not be licensed - for this feature - - )} - - )} - - + + + )} - - {standard.complianceDetails && ( - - - - - - - {standard.complianceDetails} - - - - )} - - ))} + ))} + + ))} { {/* Left Column for Accordions */} - + { updatedAt={updatedAt} /> - + {/* Show accordions based on selectedStandards (which is populated by API when editing) */} {existingTemplate.isLoading ? ( diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 8eb3e4e1dc5a..019407339e10 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -264,12 +264,20 @@ export const getCippFormatting = (data, cellName, type, canReceive) => { } if (cellName === "excludedTenants") { + // Handle null or undefined data + if (data === null || data === undefined) { + return isText ? "No data" : ; + } //check if data is an array. if (Array.isArray(data)) { return isText - ? data.join(", ") + ? data.map(item => (typeof item === 'object' && item?.label) ? item.label : item).join(", ") : data.map((item) => ( - + item && )); } }
+ This will force a Best Practice Analyzer (BPA) sync. Select a tenant (or all + tenants) below. +
+ {result.Results} +