diff --git a/cspell.json b/cspell.json index 1ff07bf80063..8d5d275d003e 100644 --- a/cspell.json +++ b/cspell.json @@ -26,6 +26,7 @@ "Rewst", "Sherweb", "Syncro", + "TERRL", "Yubikey" ], "ignoreWords": [ diff --git a/package.json b/package.json index 934681339eba..51a2591ae67b 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "react-redux": "9.2.0", "react-syntax-highlighter": "^15.6.1", "react-time-ago": "^7.3.3", + "react-virtuoso": "^4.12.8", "react-window": "^1.8.10", "redux": "5.0.1", "redux-devtools-extension": "2.13.9", diff --git a/public/version.json b/public/version.json index d18f79dee972..f47e65940e3b 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "8.0.1" -} + "version": "8.0.2" +} \ No newline at end of file diff --git a/src/components/CippCards/CippBannerListCard.jsx b/src/components/CippCards/CippBannerListCard.jsx index 32e8aee05e8d..8a123e5064b2 100644 --- a/src/components/CippCards/CippBannerListCard.jsx +++ b/src/components/CippCards/CippBannerListCard.jsx @@ -39,8 +39,8 @@ export const CippBannerListCard = (props) => { - + @@ -74,7 +74,16 @@ export const CippBannerListCard = (props) => { direction="row" flexWrap="wrap" justifyContent="space-between" - sx={{ p: 3 }} + sx={{ + p: 3, + ...(isCollapsible && { + cursor: "pointer", + "&:hover": { + bgcolor: "action.hover", + }, + }), + }} + onClick={isCollapsible ? () => handleExpand(item.id) : undefined} > {/* Left Side: cardLabelBox */} @@ -127,8 +136,16 @@ export const CippBannerListCard = (props) => { {item.statusText} )} + {item?.cardLabelBoxActions && ( + e.stopPropagation()}>{item.cardLabelBoxActions} + )} {isCollapsible && ( - handleExpand(item.id)}> + { + e.stopPropagation(); + handleExpand(item.id); + }} + > { + const [newAlias, setNewAlias] = useState(""); + + // Initialize the form field if it doesn't exist + useEffect(() => { + // Set default empty array if AddedAliases doesn't exist in the form + if (!formHook.getValues("AddedAliases")) { + formHook.setValue("AddedAliases", []); + } + }, [formHook]); + + // Use useWatch to subscribe to form field changes + const aliasList = useWatch({ + control: formHook.control, + name: "AddedAliases", + defaultValue: [], + }); + + const isPending = formHook.formState.isSubmitting; + + const handleAddAlias = () => { + if (newAlias.trim()) { + const currentAliases = formHook.getValues("AddedAliases") || []; + const newList = [...currentAliases, newAlias.trim()]; + formHook.setValue("AddedAliases", newList, { shouldValidate: true }); + setNewAlias(""); + } + }; + + const handleDeleteAlias = (aliasToDelete) => { + const currentAliases = formHook.getValues("AddedAliases") || []; + const updatedList = currentAliases.filter((alias) => alias !== aliasToDelete); + formHook.setValue("AddedAliases", updatedList, { shouldValidate: true }); + }; + + const handleKeyPress = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAddAlias(); + } + }; + + return ( + <> + + + Add proxy addresses (aliases) for this user. Enter each alias and click Add or press + Enter. + + + setNewAlias(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Enter an alias" + variant="outlined" + disabled={isPending} + size="small" + sx={{ + "& .MuiOutlinedInput-root": { + fontFamily: "monospace", + "& .MuiOutlinedInput-input": { + px: 2, + }, + }, + }} + /> + + + + {aliasList.length === 0 ? ( + + No aliases added yet + + ) : ( + aliasList.map((alias) => ( + handleDeleteAlias(alias)} + color="primary" + variant="outlined" + /> + )) + )} + + + + ); +}; + +export default CippAliasDialog; diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index 6ec2daaad67b..001d00d167b0 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -1,5 +1,6 @@ import { useRouter } from "next/router"; import { + Box, Button, Dialog, DialogActions, @@ -7,11 +8,11 @@ import { DialogTitle, useMediaQuery, } from "@mui/material"; -import { Stack, Grid } from "@mui/system"; +import { Stack } from "@mui/system"; import { CippApiResults } from "./CippApiResults"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import React, { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { useSettings } from "../../hooks/use-settings"; import CippFormComponent from "./CippFormComponent"; @@ -25,6 +26,7 @@ export const CippApiDialog = (props) => { relatedQueryKeys, dialogAfterEffect, allowResubmit = false, + children, ...other } = props; const router = useRouter(); @@ -36,7 +38,6 @@ export const CippApiDialog = (props) => { if (mdDown) { other.fullScreen = true; } - useEffect(() => { if (createDialog.open) { setIsFormSubmitted(false); @@ -74,13 +75,17 @@ export const CippApiDialog = (props) => { }); const processActionData = (dataObject, row, replacementBehaviour) => { - if (typeof api?.dataFunction === "function") return api.dataFunction(row); + if (typeof api?.dataFunction === "function") return api.dataFunction(row, dataObject); let newData = {}; if (api?.postEntireRow) { return row; } + if (!dataObject) { + return dataObject; + } + Object.keys(dataObject).forEach((key) => { const value = dataObject[key]; @@ -106,57 +111,65 @@ export const CippApiDialog = (props) => { const tenantFilter = useSettings().currentTenant; const handleActionClick = (row, action, formData) => { setIsFormSubmitted(true); - if (action.multiPost === undefined) action.multiPost = false; + let finalData = {}; + if (typeof api?.customDataformatter === "function") { + finalData = api.customDataformatter(row, action, formData); + } else { + if (action.multiPost === undefined) action.multiPost = false; - if (api.customFunction) { - action.customFunction(row, action, formData); - createDialog.handleClose(); - return; - } + if (api.customFunction) { + action.customFunction(row, action, formData); + createDialog.handleClose(); + return; + } - const commonData = { - tenantFilter, - ...formData, - ...addedFieldData, - }; - const processedActionData = processActionData(action.data, row, action.replacementBehaviour); + const commonData = { + tenantFilter, + ...formData, + ...addedFieldData, + }; + const processedActionData = processActionData(action.data, row, action.replacementBehaviour); - // MULTI ROW CASES - if (Array.isArray(row)) { - const arrayData = row.map((singleRow) => { - const itemData = { ...commonData }; - Object.keys(processedActionData).forEach((key) => { - const rowValue = singleRow[processedActionData[key]]; - itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key]; - }); - return itemData; - }); + if (!processedActionData || Object.keys(processedActionData).length === 0) { + console.warn("No data to process for action:", action); + } else { + // MULTI ROW CASES + if (Array.isArray(row)) { + const arrayData = row.map((singleRow) => { + const itemData = { ...commonData }; + Object.keys(processedActionData).forEach((key) => { + const rowValue = singleRow[processedActionData[key]]; + itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key]; + }); + return itemData; + }); - const payload = { - url: action.url, - bulkRequest: !action.multiPost, - data: arrayData, - }; + const payload = { + url: action.url, + bulkRequest: !action.multiPost, + data: arrayData, + }; - if (action.type === "POST") { - actionPostRequest.mutate(payload); - } else if (action.type === "GET") { - setGetRequestInfo({ - ...payload, - waiting: true, - queryKey: Date.now(), - }); - } + if (action.type === "POST") { + actionPostRequest.mutate(payload); + } else if (action.type === "GET") { + setGetRequestInfo({ + ...payload, + waiting: true, + queryKey: Date.now(), + }); + } - return; + return; + } + } + // ✅ FIXED: DIRECT MERGE INSTEAD OF CORRUPT TRANSFORMATION + finalData = { + ...commonData, + ...processedActionData, + }; } - // ✅ FIXED: DIRECT MERGE INSTEAD OF CORRUPT TRANSFORMATION - const finalData = { - ...commonData, - ...processedActionData, - }; - if (action.type === "POST") { actionPostRequest.mutate({ url: action.url, @@ -303,18 +316,31 @@ export const CippApiDialog = (props) => { {confirmText} - - {fields?.map((fieldProps, i) => ( - - - - ))} - + + {children ? ( + typeof children === "function" ? ( + children({ + formHook, + row, + }) + ) : ( + children + ) + ) : ( + <> + {fields?.map((fieldProps, i) => ( + + + + ))} + + )} + diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index edf5826eccba..3a9dd724b6f1 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -1,4 +1,4 @@ -import { Close, Download } from "@mui/icons-material"; +import { Close, Download, Help } from "@mui/icons-material"; import { Alert, CircularProgress, @@ -9,10 +9,13 @@ import { Box, SvgIcon, Tooltip, + Button, + keyframes, } from "@mui/material"; import { useEffect, useState, useMemo, useCallback } from "react"; import { getCippError } from "../../utils/get-cipp-error"; import { CippCopyToClipBoard } from "./CippCopyToClipboard"; +import { CippDocsLookup } from "./CippDocsLookup"; import React from "react"; import { CippTableDialog } from "./CippTableDialog"; import { EyeIcon } from "@heroicons/react/24/outline"; @@ -275,7 +278,38 @@ export const CippApiResults = (props) => { severity={resultObj.severity || "success"} action={ <> + {resultObj.severity === "error" && ( + + )} + { + const permissionLevel = useWatch({ + control: formHook.control, + name: "Permissions", + }); + + const userSettingsDefaults = useSettings(); + + const usersList = ApiGetCall({ + url: "/api/ListGraphRequest", + data: { + Endpoint: `users`, + tenantFilter: userSettingsDefaults.currentTenant, + $select: "id,displayName,userPrincipalName,mail", + noPagination: true, + $top: 999, + }, + queryKey: `UserNames-${userSettingsDefaults.currentTenant}`, + }); + + const isEditor = permissionLevel?.value === "Editor"; + + useEffect(() => { + if (!isEditor) { + formHook.setValue("CanViewPrivateItems", false); + } + }, [isEditor, formHook]); + + return ( + + + ({ + value: user.userPrincipalName, + label: `${user.displayName} (${user.userPrincipalName})`, + })) || [] + } + required={true} + validators={{ + validate: (value) => (value ? true : "Select a user to assign permissions to"), + }} + placeholder="Select a user to assign permissions to" + /> + + + (value ? true : "Select the permission level for the calendar"), + }} + options={[ + { value: "Author", label: "Author" }, + { value: "Contributor", label: "Contributor" }, + { value: "Editor", label: "Editor" }, + { value: "Owner", label: "Owner" }, + { value: "NonEditingAuthor", label: "Non Editing Author" }, + { value: "PublishingAuthor", label: "Publishing Author" }, + { value: "PublishingEditor", label: "Publishing Editor" }, + { value: "Reviewer", label: "Reviewer" }, + { value: "LimitedDetails", label: "Limited Details" }, + { value: "AvailabilityOnly", label: "Availability Only" }, + ]} + multiple={false} + formControl={formHook} + /> + + + + + + + + + + ); +}; + +export default CippCalendarPermissionsDialog; diff --git a/src/components/CippComponents/CippDocsLookup.jsx b/src/components/CippComponents/CippDocsLookup.jsx new file mode 100644 index 000000000000..987809b24f2c --- /dev/null +++ b/src/components/CippComponents/CippDocsLookup.jsx @@ -0,0 +1,71 @@ +import { Search } from "@mui/icons-material"; +import { Chip, IconButton, SvgIcon, Tooltip } from "@mui/material"; +import { useState } from "react"; + +export const CippDocsLookup = (props) => { + const { text, type = "button", visible = true, ...other } = props; + const [showPassword, setShowPassword] = useState(false); + + const handleTogglePassword = () => { + setShowPassword((prev) => !prev); + }; + + const handleDocsLookup = () => { + const searchUrl = `https://docs.cipp.app/?q=Help+with:+${encodeURIComponent(text)}&ask=true`; + window.open(searchUrl, '_blank'); + }; + + if (!visible) return null; + + if (type === "button") { + return ( + + + + + + + + ); + } + + if (type === "chip") { + return ( + + + + ); + } + + if (type === "password") { + return ( + <> + + + {showPassword ? : } + + + + + + + ); + } + + return null; +}; \ No newline at end of file diff --git a/src/components/CippComponents/CippMailboxPermissionsDialog.jsx b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx new file mode 100644 index 000000000000..52b2f3cb7372 --- /dev/null +++ b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx @@ -0,0 +1,87 @@ +import { Box, Stack } from "@mui/material"; +import CippFormComponent from "./CippFormComponent"; +import { useWatch } from "react-hook-form"; +import { ApiGetCall } from "../../api/ApiCall"; +import { useSettings } from "../../hooks/use-settings"; + +const CippMailboxPermissionsDialog = ({ formHook }) => { + const fullAccess = useWatch({ + control: formHook.control, + name: "permissions.AddFullAccess", + }); + + const userSettingsDefaults = useSettings(); + + const usersList = ApiGetCall({ + url: "/api/ListGraphRequest", + data: { + Endpoint: `users`, + tenantFilter: userSettingsDefaults.currentTenant, + $select: "id,displayName,userPrincipalName,mail", + noPagination: true, + $top: 999, + }, + queryKey: `UserNames-${userSettingsDefaults.currentTenant}`, + }); + + return ( + + + ({ + value: user.userPrincipalName, + label: `${user.displayName} (${user.userPrincipalName})`, + })) || [] + } + /> + {fullAccess && ( + + )} + + + ({ + value: user.userPrincipalName, + label: `${user.displayName} (${user.userPrincipalName})`, + })) || [] + } + /> + + + ({ + value: user.userPrincipalName, + label: `${user.displayName} (${user.userPrincipalName})`, + })) || [] + } + /> + + + ); +}; + +export default CippMailboxPermissionsDialog; diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx index 61d0c32e37fd..cb3db82c1645 100644 --- a/src/components/CippComponents/CippTenantSelector.jsx +++ b/src/components/CippComponents/CippTenantSelector.jsx @@ -43,6 +43,7 @@ export const CippTenantSelector = (props) => { toast: true, }); + // This effect handles updates when the tenant is changed via dropdown selection useEffect(() => { if (!router.isReady) return; if (currentTenant?.value) { @@ -65,14 +66,62 @@ export const CippTenantSelector = (props) => { } }, [currentTenant?.value]); + // This effect handles when the URL parameter changes externally + useEffect(() => { + if (!router.isReady || !tenantList.isSuccess) return; + + // Get the current tenant from URL or settings + const urlTenant = router.query.tenantFilter || settings.currentTenant; + + // Only update if there's a URL tenant and it's different from our current state + if (urlTenant && (!currentTenant || urlTenant !== currentTenant.value)) { + // Find the tenant in our list + const matchingTenant = tenantList.data.find( + ({ defaultDomainName }) => defaultDomainName === urlTenant + ); + + if (matchingTenant) { + setSelectedTenant({ + value: urlTenant, + label: `${matchingTenant.displayName} (${urlTenant})`, + addedFields: { + defaultDomainName: matchingTenant.defaultDomainName, + displayName: matchingTenant.displayName, + customerId: matchingTenant.customerId, + initialDomainName: matchingTenant.initialDomainName, + }, + }); + } + } + }, [router.isReady, router.query.tenantFilter, tenantList.isSuccess, settings.currentTenant]); + + // This effect ensures the tenant filter parameter is included in the URL when missing + useEffect(() => { + if (!router.isReady || !settings.currentTenant) return; + + // If the tenant parameter is missing from the URL but we have it in settings + if (!router.query.tenantFilter && settings.currentTenant) { + const query = { ...router.query, tenantFilter: settings.currentTenant }; + router.replace( + { + pathname: router.pathname, + query: query, + }, + undefined, + { shallow: true } + ); + } + }, [router.isReady, router.query, settings.currentTenant]); + useEffect(() => { if (tenant && currentTenant?.value && currentTenant?.value !== "AllTenants") { tenantDetails.refetch(); } }, [tenant, offcanvasVisible]); + // We can simplify this effect since we now have the new effect above to handle URL changes useEffect(() => { - if (tenant && tenantList.isSuccess) { + if (tenant && tenantList.isSuccess && !currentTenant) { const matchingTenant = tenantList.data.find( ({ defaultDomainName }) => defaultDomainName === tenant ); @@ -94,7 +143,8 @@ export const CippTenantSelector = (props) => { } ); } - }, [tenant, tenantList.isSuccess]); + }, [tenant, tenantList.isSuccess, currentTenant]); + return ( <> { const userSettingsDefaults = useSettings(); @@ -28,6 +29,31 @@ const CippExchangeSettingsForm = (props) => { const [expandedPanel, setExpandedPanel] = useState(null); const [relatedQueryKeys, setRelatedQueryKeys] = useState([]); + // Watch the Auto Reply State value + const autoReplyState = useWatch({ + control: formControl.control, + name: "ooo.AutoReplyState", + }); + + // Calculate if date fields should be disabled + const areDateFieldsDisabled = autoReplyState?.value !== "Scheduled"; + + useEffect(() => { + console.log('Auto Reply State changed:', { + autoReplyState, + areDateFieldsDisabled, + fullFormValues: formControl.getValues() + }); + }, [autoReplyState]); + + // Add debug logging for form values + useEffect(() => { + const subscription = formControl.watch((value, { name, type }) => { + console.log('Form value changed:', { name, type, value }); + }); + return () => subscription.unsubscribe(); + }, [formControl]); + const handleExpand = (panel) => { setExpandedPanel((prev) => (prev === panel ? null : panel)); }; @@ -50,9 +76,7 @@ const CippExchangeSettingsForm = (props) => { }); const handleSubmit = (type) => { - if (type === "permissions") { - setRelatedQueryKeys([`Mailbox-${userId}`]); - } else if (type === "calendar") { + if (type === "calendar") { setRelatedQueryKeys([`CalendarPermissions-${userId}`]); } else if (type === "forwarding") { setRelatedQueryKeys([`Mailbox-${userId}`]); @@ -83,7 +107,6 @@ const CippExchangeSettingsForm = (props) => { } }); const url = { - permissions: "/api/ExecEditMailboxPermissions", calendar: "/api/ExecEditCalendarPermissions", forwarding: "/api/ExecEmailForward", ooo: "/api/ExecSetOoO", @@ -101,308 +124,6 @@ const CippExchangeSettingsForm = (props) => { // Data for each section const sections = [ - { - id: "mailboxPermissions", - cardLabelBox: "-", - text: "Mailbox Permissions", - subtext: "Manage mailbox permissions for users", - formContent: ( - - {/* Full Access Section */} - - Full Access - - Manage who has full access to this mailbox - - - - currentSettings?.Permissions?.some( - (perm) => - perm.AccessRights === "FullAccess" && perm.User === user.userPrincipalName - ) - ).map((user) => ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - - - - {/* Send As Section */} - - Send As - - Manage who can send emails as this user - - - - currentSettings?.Permissions?.some( - (perm) => perm.AccessRights === "SendAs" && perm.User === user.userPrincipalName - ) - ).map((user) => ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - - - - {/* Send On Behalf Section */} - - Send On Behalf - - Manage who can send emails on behalf of this user - - - - currentSettings?.Permissions?.some( - (perm) => - perm.AccessRights === "SendOnBehalf" && perm.User === user.userPrincipalName - ) - ).map((user) => ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - - - - - - - - - - - ), - }, - { - id: "calendarPermissions", - cardLabelBox: "-", - text: "Calendar Permissions", - subtext: "Adjust calendar sharing settings", - formContent: ( - - - calPermissions?.some((perm) => perm.User === user.displayName) - ).map((user) => ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || [] - } - formControl={formControl} - /> - ({ - value: user.userPrincipalName, - label: `${user.displayName} (${user.userPrincipalName})`, - })) || []), - ]} - multiple={false} - formControl={formControl} - /> - - - - value ? true : "Select the permission level for the calendar", - }} - isFetching={isFetching || usersList.isFetching} - options={[ - { value: "Author", label: "Author" }, - { value: "Contributor", label: "Contributor" }, - { value: "Editor", label: "Editor" }, - { value: "Owner", label: "Owner" }, - { value: "NonEditingAuthor", label: "Non Editing Author" }, - { value: "PublishingAuthor", label: "Publishing Author" }, - { value: "PublishingEditor", label: "Publishing Editor" }, - { value: "Reviewer", label: "Reviewer" }, - { value: "LimitedDetails", label: "Limited Details" }, - { value: "AvailabilityOnly", label: "Availability Only" }, - ]} - multiple={false} - formControl={formControl} - /> - - {(() => { - const permissionLevel = useWatch({ - control: formControl.control, - name: "calendar.Permissions" - }); - const isEditor = permissionLevel?.value === "Editor"; - - // Use useEffect to handle the switch value reset - useEffect(() => { - if (!isEditor) { - formControl.setValue("calendar.CanViewPrivateItems", false); - } - }, [isEditor, formControl]); - - return ( - - - - - - ); - })()} - - - - - - - - - - - - ), - }, { id: "mailboxForwarding", cardLabelBox: currentSettings?.ForwardAndDeliver ? : "-", @@ -503,20 +224,36 @@ const CippExchangeSettingsForm = (props) => { /> - + + + + + - + + + + + { alignItems: "center", display: "flex", justifyContent: "space-between", - p: 2, + py: 3, + pl: 2, + pr: 4, + cursor: "pointer", + "&:hover": { + bgcolor: "action.hover", + }, }} + onClick={() => handleExpand(section.id)} > {/* Left Side: cardLabelBox, text, subtext */} @@ -631,18 +375,15 @@ const CippExchangeSettingsForm = (props) => { - {/* Expand Icon */} - handleExpand(section.id)}> - - - - + + + diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index 4048981e6cd5..c07bc67c27d2 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -24,6 +24,9 @@ import { Search, Close, FilterAlt, + NotificationImportant, + Assignment, + Construction, } from "@mui/icons-material"; import { Grid } from "@mui/system"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; @@ -94,81 +97,191 @@ const CippStandardAccordion = ({ const [configuredState, setConfiguredState] = useState({}); const [filter, setFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); + const [savedValues, setSavedValues] = useState({}); + const [originalValues, setOriginalValues] = useState({}); const watchedValues = useWatch({ control: formControl.control, }); + // Check if a standard is configured based on its values + const isStandardConfigured = (standardName, standard, values) => { + if (!values) return false; + + // ALWAYS require an action for any standard to be considered configured + // The action field should be an array with at least one element + const actionValue = _.get(values, "action"); + if (!actionValue || (Array.isArray(actionValue) && actionValue.length === 0)) return false; + + // Additional checks for required components + const hasRequiredComponents = + standard.addedComponent && + standard.addedComponent.some((comp) => comp.type !== "switch" && comp.required !== false); + const actionRequired = standard.disabledFeatures !== undefined || hasRequiredComponents; + + // Always require an action (should be an array with at least one element) + const actionFilled = actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); + + const addedComponentsFilled = + standard.addedComponent?.every((component) => { + // Always skip switches + if (component.type === "switch") return true; + + // Handle conditional fields + if (component.condition) { + const conditionField = component.condition.field; + const conditionValue = _.get(values, conditionField); + const compareType = component.condition.compareType || "is"; + const compareValue = component.condition.compareValue; + const propertyName = component.condition.propertyName || "value"; + + let conditionMet = false; + if (propertyName === "value") { + switch (compareType) { + case "is": + conditionMet = _.isEqual(conditionValue, compareValue); + break; + case "isNot": + conditionMet = !_.isEqual(conditionValue, compareValue); + break; + default: + conditionMet = false; + } + } else if (Array.isArray(conditionValue)) { + switch (compareType) { + case "valueEq": + conditionMet = conditionValue.some((item) => item?.[propertyName] === compareValue); + break; + default: + conditionMet = false; + } + } + + // If condition is not met, skip validation for this field + if (!conditionMet) return true; + } + + // Check if field is required + const isRequired = component.required !== false; + if (!isRequired) return true; + + // Get field value using lodash's get to properly handle nested properties + const fieldValue = _.get(values, component.name); + + // Check if field has a value based on its type and multiple property + if (component.type === "autoComplete" || component.type === "select") { + if (component.multiple) { + // For multiple selection, check if array exists and has items + return Array.isArray(fieldValue) && fieldValue.length > 0; + } else { + // For single selection, check if value exists + return !!fieldValue; + } + } + + // For other field types + return !!fieldValue; + }) ?? true; + + return actionFilled && addedComponentsFilled; + }; + + // Initialize when watchedValues are available useEffect(() => { - const newConfiguredState = { ...configuredState }; + // Only run initialization if we have watchedValues and they contain data + if (!watchedValues || Object.keys(watchedValues).length === 0) { + return; + } - Object.keys(selectedStandards).forEach((standardName) => { - const standard = providedStandards.find((s) => s.name === standardName.split("[")[0]); - if (standard) { - const actionFilled = !!_.get(watchedValues, `${standardName}.action`, false); - - const addedComponentsFilled = - standard.addedComponent?.every((component) => { - // Skip validation for components with conditions - if (component.condition) { - const conditionField = `${standardName}.${component.condition.field}`; - const conditionValue = _.get(watchedValues, conditionField); - const compareType = component.condition.compareType || "is"; - const compareValue = component.condition.compareValue; - const propertyName = component.condition.propertyName || "value"; - - // Check if condition is met based on the compareType - let conditionMet = false; - if (propertyName === "value") { - switch (compareType) { - case "is": - conditionMet = _.isEqual(conditionValue, compareValue); - break; - case "isNot": - conditionMet = !_.isEqual(conditionValue, compareValue); - break; - // Add other compareType cases as needed - default: - conditionMet = false; - } - } else if (Array.isArray(conditionValue)) { - // Handle array values with propertyName - switch (compareType) { - case "valueEq": - conditionMet = conditionValue.some( - (item) => item?.[propertyName] === compareValue - ); - break; - // Add other compareType cases for arrays as needed - default: - conditionMet = false; - } - } - - // If condition is not met, we don't need to validate this field - if (!conditionMet) { - return true; - } - } + // Prevent re-initialization if we already have configuration state + const hasConfigState = Object.keys(configuredState).length > 0; + if (hasConfigState) { + return; + } - const isRequired = component.required !== false && component.type !== "switch"; - if (!isRequired) return true; - return !!_.get(watchedValues, `${standardName}.${component.name}`); - }) ?? true; + console.log("Initializing configuration state from template values"); + const initial = {}; + const initialConfigured = {}; - const isConfigured = actionFilled && addedComponentsFilled; + // For each standard, get its current values and determine if it's configured + Object.keys(selectedStandards).forEach((standardName) => { + const currentValues = _.get(watchedValues, standardName); + if (!currentValues) return; - if (newConfiguredState[standardName] !== isConfigured) { - newConfiguredState[standardName] = isConfigured; - } + initial[standardName] = _.cloneDeep(currentValues); + + const baseStandardName = standardName.split("[")[0]; + const standard = providedStandards.find((s) => s.name === baseStandardName); + if (standard) { + initialConfigured[standardName] = isStandardConfigured( + standardName, + standard, + currentValues + ); } }); - if (!_.isEqual(newConfiguredState, configuredState)) { - setConfiguredState(newConfiguredState); + // Store both the initial values and set them as current saved values + setOriginalValues(initial); + setSavedValues(initial); + setConfiguredState(initialConfigured); + // Only depend on watchedValues and selectedStandards to avoid infinite loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [watchedValues, selectedStandards]); + + // Save changes for a standard + const handleSave = (standardName, standard, current) => { + // Clone the current values to avoid reference issues + const newValues = _.cloneDeep(current); + + // Update saved values + setSavedValues((prev) => ({ + ...prev, + [standardName]: newValues, + })); + + // Update configured state right away + const isConfigured = isStandardConfigured(standardName, standard, newValues); + console.log(`Saving standard ${standardName}, configured: ${isConfigured}`); + + setConfiguredState((prev) => ({ + ...prev, + [standardName]: isConfigured, + })); + + // Collapse the accordion after saving + handleAccordionToggle(null); + }; + + // Cancel changes for a standard + const handleCancel = (standardName) => { + // Get the last saved values + const savedValue = _.get(savedValues, standardName); + if (!savedValue) return; + + // Set the entire standard's value at once to ensure proper handling of nested objects and arrays + formControl.setValue(standardName, _.cloneDeep(savedValue)); + + // Find the original standard definition to get the base standard + const baseStandardName = standardName.split("[")[0]; + const standard = providedStandards.find((s) => s.name === baseStandardName); + + // Determine if the standard was configured with saved values + if (standard) { + const isConfigured = isStandardConfigured(standardName, standard, savedValue); + + // Restore the previous configuration state + setConfiguredState((prev) => ({ + ...prev, + [standardName]: isConfigured, + })); } - }, [watchedValues, providedStandards, selectedStandards]); + // Collapse the accordion after canceling + handleAccordionToggle(null); + }; + + // Group standards by category const groupedStandards = useMemo(() => { const result = {}; @@ -197,6 +310,7 @@ const CippStandardAccordion = ({ return result; }, [selectedStandards, providedStandards]); + // Filter standards based on search and filter selection const filteredGroupedStandards = useMemo(() => { if (!searchQuery && filter === "all") { return groupedStandards; @@ -209,6 +323,11 @@ const CippStandardAccordion = ({ const categoryMatchesSearch = !searchQuery || category.toLowerCase().includes(searchLower); const filteredStandards = groupedStandards[category].filter(({ standardName, standard }) => { + // If this is the currently expanded standard, always include it in the result + if (standardName === expanded) { + return true; + } + const matchesSearch = !searchQuery || categoryMatchesSearch || @@ -219,7 +338,7 @@ const CippStandardAccordion = ({ Array.isArray(standard.tag) && standard.tag.some((tag) => tag.toLowerCase().includes(searchLower))); - const isConfigured = configuredState[standardName]; + const isConfigured = _.get(configuredState, standardName); const matchesFilter = filter === "all" || (filter === "configured" && isConfigured) || @@ -236,6 +355,7 @@ const CippStandardAccordion = ({ return result; }, [groupedStandards, searchQuery, filter, configuredState]); + // Count standards by configuration state const standardCounts = useMemo(() => { let allCount = 0; let configuredCount = 0; @@ -278,7 +398,13 @@ const CippStandardAccordion = ({ sx={{ width: { xs: "100%", sm: 350 } }} placeholder="Search..." value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { + // Close any expanded accordion when changing search query + if (expanded && e.target.value !== searchQuery) { + handleAccordionToggle(null); + } + setSearchQuery(e.target.value); + }} slotProps={{ input: { startAdornment: ( @@ -291,7 +417,13 @@ const CippStandardAccordion = ({ setSearchQuery("")} + onClick={() => { + // Close any expanded accordion when clearing search + if (expanded) { + handleAccordionToggle(null); + } + setSearchQuery(""); + }} aria-label="Clear search" > @@ -311,19 +443,37 @@ const CippStandardAccordion = ({ @@ -350,7 +500,7 @@ const CippStandardAccordion = ({ const isExpanded = expanded === standardName; const hasAddedComponents = standard.addedComponent && standard.addedComponent.length > 0; - const isConfigured = configuredState[standardName]; + const isConfigured = _.get(configuredState, standardName); const disabledFeatures = standard.disabledFeatures || {}; let selectedActions = _.get(watchedValues, `${standardName}.action`); @@ -361,9 +511,106 @@ const CippStandardAccordion = ({ const selectedTemplateName = standard.multiple ? _.get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`) : ""; - const accordionTitle = selectedTemplateName - ? `${standard.label} - ${selectedTemplateName.label}` - : standard.label; + const accordionTitle = + selectedTemplateName && _.get(selectedTemplateName, "label") + ? `${standard.label} - ${_.get(selectedTemplateName, "label")}` + : standard.label; + + // Get current values and check if they differ from saved values + const current = _.get(watchedValues, standardName); + const saved = _.get(savedValues, standardName) || {}; + const hasUnsaved = !_.isEqual(current, saved); + + // Check if all required fields are filled + const requiredFieldsFilled = current + ? standard.addedComponent?.every((component) => { + // Always skip switches regardless of their required property + if (component.type === "switch") return true; + + // Skip optional fields (not required) + const isRequired = component.required !== false; + if (!isRequired) return true; + + // Handle conditional fields + if (component.condition) { + const conditionField = component.condition.field; + const conditionValue = _.get(current, conditionField); + const compareType = component.condition.compareType || "is"; + const compareValue = component.condition.compareValue; + const propertyName = component.condition.propertyName || "value"; + + let conditionMet = false; + if (propertyName === "value") { + switch (compareType) { + case "is": + conditionMet = _.isEqual(conditionValue, compareValue); + break; + case "isNot": + conditionMet = !_.isEqual(conditionValue, compareValue); + break; + default: + conditionMet = false; + } + } else if (Array.isArray(conditionValue)) { + switch (compareType) { + case "valueEq": + conditionMet = conditionValue.some( + (item) => item?.[propertyName] === compareValue + ); + break; + default: + conditionMet = false; + } + } + + // If condition is not met, skip validation + if (!conditionMet) return true; + } + + // Get field value for validation using lodash's get to properly handle nested properties + const fieldValue = _.get(current, component.name); + console.log(`Checking field: ${component.name}, value:`, fieldValue); + console.log(current); + // Check if required field has a value based on its type and multiple property + if (component.type === "autoComplete" || component.type === "select") { + if (component.multiple) { + // For multiple selection, check if array exists and has items + return Array.isArray(fieldValue) && fieldValue.length > 0; + } else { + // For single selection, check if value exists + return !!fieldValue; + } + } + + // For other field types + return !!fieldValue; + }) ?? true + : false; + + // ALWAYS require an action for all standards + const actionRequired = true; + + // Check if there are required non-switch components for UI display purposes + const hasRequiredComponents = + standard.addedComponent && + standard.addedComponent.some( + (comp) => comp.type !== "switch" && comp.required !== false + ); + + // Action is always required and must be an array with at least one element + const actionValue = _.get(current, "action"); + const hasAction = + actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); + + // Allow saving if: + // 1. Action is selected if required + // 2. All required fields are filled + // 3. There are unsaved changes + const canSave = hasAction && requiredFieldsFilled && hasUnsaved; + + console.log( + `Standard: ${standardName}, Action Required: ${actionRequired}, Has Action: ${hasAction}, Required Fields Filled: ${requiredFieldsFilled}, Can Save: ${canSave}` + ); return ( @@ -391,28 +638,37 @@ const CippStandardAccordion = ({ {accordionTitle} - {selectedActions && selectedActions?.length > 0 && ( - - {selectedActions?.map((action, index) => ( - - - - ))} - - - )} + + {selectedActions && selectedActions?.length > 0 && ( + <> + {selectedActions?.map((action, index) => ( + + + {action.value === "Report" && } + {action.value === "warn" && } + {action.value === "Remediate" && } + + } + /> + + ))} + + )} + + {standard.helpText} @@ -456,6 +712,7 @@ const CippStandardAccordion = ({ + {/* Always show action field as it's required */} @@ -501,6 +759,27 @@ const CippStandardAccordion = ({ )} + + + + + + + ); diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx index bdf407da2bf4..73bf59cb04f9 100644 --- a/src/components/CippStandards/CippStandardDialog.jsx +++ b/src/components/CippStandards/CippStandardDialog.jsx @@ -1,4 +1,4 @@ -import { differenceInDays } from 'date-fns'; +import { differenceInDays } from "date-fns"; import { Dialog, DialogActions, @@ -14,11 +14,252 @@ import { Switch, Button, IconButton, + CircularProgress, } from "@mui/material"; import { Grid } from "@mui/system"; import { Add } from "@mui/icons-material"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo, memo, useEffect, Suspense, lazy } from "react"; import { debounce } from "lodash"; +import { Virtuoso } from "react-virtuoso"; + +// Memoized Standard Card component to prevent unnecessary re-renders +const StandardCard = memo( + ({ + standard, + category, + selectedStandards, + handleToggleSingleStandard, + handleAddClick, + isButtonDisabled, + }) => { + const isNewStandard = (dateAdded) => { + const currentDate = new Date(); + const addedDate = new Date(dateAdded); + return differenceInDays(currentDate, addedDate) <= 30; + }; + + // Create a memoized handler for this specific standard to avoid recreation on each render + const handleToggle = useCallback(() => { + handleToggleSingleStandard(standard.name); + }, [handleToggleSingleStandard, standard.name]); + + // Check if this standard is selected - memoize for better performance + const isSelected = useMemo(() => { + return !!selectedStandards[standard.name]; + }, [selectedStandards, standard.name]); + + // Lazily render complex parts of the card only when visible + const [expanded, setExpanded] = useState(false); + + // Use intersection observer to detect when card is visible + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setExpanded(true); + observer.disconnect(); + } + }, + { threshold: 0.1 } + ); + + const currentRef = document.getElementById(`standard-card-${standard.name}`); + if (currentRef) { + observer.observe(currentRef); + } + + return () => observer.disconnect(); + }, [standard.name]); + + return ( + + + {isNewStandard(standard.addedDate) && ( + + )} + + + {standard.label} + + {expanded && standard.helpText && ( + <> + + Description: + + + {standard.helpText} + + + )} + + Category: + + + {expanded && + standard.tag?.filter((tag) => !tag.toLowerCase().includes("impact")).length > 0 && ( + <> + + Tags: + + + {standard.tag + .filter((tag) => !tag.toLowerCase().includes("impact")) + .map((tag, idx) => ( + + ))} + + + )} + + Impact: + + + {expanded && standard.recommendedBy?.length > 0 && ( + <> + + Recommended By: + + + {standard.recommendedBy.join(", ")} + + + )} + {expanded && standard.addedDate?.length > 0 && ( + <> + + Date Added: + + + + {standard.addedDate} + + + + )} + + + + {standard.multiple ? ( + handleAddClick(standard.name)} + > + + + ) : ( + + } + label="Add this standard to the template" + /> + )} + + + + ); + }, + // Custom equality function to prevent unnecessary re-renders + (prevProps, nextProps) => { + // Only re-render if one of these props changed + if (prevProps.isButtonDisabled !== nextProps.isButtonDisabled) return false; + if (prevProps.standard.name !== nextProps.standard.name) return false; + + // Only check selected state for this specific standard + const prevSelected = !!prevProps.selectedStandards[prevProps.standard.name]; + const nextSelected = !!nextProps.selectedStandards[nextProps.standard.name]; + if (prevSelected !== nextSelected) return false; + + // If we get here, nothing important changed, skip re-render + return true; + } +); + +StandardCard.displayName = "StandardCard"; + +// Virtualized grid to handle large numbers of standards efficiently +const VirtualizedStandardGrid = memo(({ items, renderItem }) => { + const [itemsPerRow, setItemsPerRow] = useState(() => + window.innerWidth > 960 ? 4 : window.innerWidth > 600 ? 2 : 1 + ); + + // Handle window resize for responsive grid + useEffect(() => { + const handleResize = () => { + const newItemsPerRow = window.innerWidth > 960 ? 4 : window.innerWidth > 600 ? 2 : 1; + setItemsPerRow(newItemsPerRow); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const rows = useMemo(() => { + const rowCount = Math.ceil(items.length / itemsPerRow); + const rowsData = []; + + for (let i = 0; i < rowCount; i++) { + const startIdx = i * itemsPerRow; + const rowItems = items.slice(startIdx, startIdx + itemsPerRow); + rowsData.push(rowItems); + } + + return rowsData; + }, [items, itemsPerRow]); + + return ( + ( + + + {rows[index].map(renderItem)} + + + )} + /> + ); +}); + +VirtualizedStandardGrid.displayName = "VirtualizedStandardGrid"; const CippStandardDialog = ({ dialogOpen, @@ -31,35 +272,120 @@ const CippStandardDialog = ({ handleAddMultipleStandard, }) => { const [isButtonDisabled, setButtonDisabled] = useState(false); + const [localSearchQuery, setLocalSearchQuery] = useState(""); + const [isInitialLoading, setIsInitialLoading] = useState(true); - const handleAddClick = (standardName) => { - setButtonDisabled(true); - handleAddMultipleStandard(standardName); - - setTimeout(() => { - setButtonDisabled(false); - }, 100); - }; + // Optimize handleAddClick to be more performant + const handleAddClick = useCallback( + (standardName) => { + setButtonDisabled(true); + handleAddMultipleStandard(standardName); + // Use requestAnimationFrame for smoother UI updates + requestAnimationFrame(() => { + setTimeout(() => { + setButtonDisabled(false); + }, 100); + }); + }, + [handleAddMultipleStandard] + ); + // Optimize search debounce with a higher timeout for better performance const handleSearchQueryChange = useCallback( debounce((query) => { setSearchQuery(query.trim()); - }, 50), - [] + }, 350), // Increased debounce time for better performance + [setSearchQuery] ); - const isNewStandard = (dateAdded) => { - const currentDate = new Date(); - const addedDate = new Date(dateAdded); - return differenceInDays(currentDate, addedDate) <= 30; - }; + // Only process visible categories on demand to improve performance + const [processedItems, setProcessedItems] = useState([]); + + // Handle search input change locally + const handleLocalSearchChange = useCallback( + (e) => { + const value = e.target.value.toLowerCase(); + setLocalSearchQuery(value); + handleSearchQueryChange(value); + }, + [handleSearchQueryChange] + ); + + // Clear dialog state on close + const handleClose = useCallback(() => { + setLocalSearchQuery(""); // Clear local search state + handleSearchQueryChange(""); // Clear parent search state + handleCloseDialog(); + }, [handleCloseDialog, handleSearchQueryChange]); + + // Process standards data only when dialog is opened, to improve performance + useEffect(() => { + if (dialogOpen) { + // Use requestIdleCallback if available, or setTimeout as fallback + const processStandards = () => { + // Create a flattened list of all standards for virtualized rendering + const allItems = []; + + Object.keys(categories).forEach((category) => { + const filteredStandards = filterStandards(categories[category]); + filteredStandards.forEach((standard) => { + allItems.push({ + standard, + category, + }); + }); + }); + + setProcessedItems(allItems); + setIsInitialLoading(false); + }; + if (window.requestIdleCallback) { + window.requestIdleCallback(processStandards, { timeout: 500 }); + } else { + setTimeout(processStandards, 100); + } + + return () => { + if (window.cancelIdleCallback) { + window.cancelIdleCallback(processStandards); + } + }; + } else { + setIsInitialLoading(true); + } + }, [dialogOpen, categories, filterStandards, localSearchQuery]); + + // Render individual standard card + const renderStandardCard = useCallback( + ({ standard, category }) => ( + + ), + [selectedStandards, handleToggleSingleStandard, handleAddClick, isButtonDisabled] + ); + + // Don't render dialog contents until it's actually open (improves performance) return ( { + // Clear processed items on dialog close to free up memory + setProcessedItems([]); + }, + }} PaperProps={{ sx: { minWidth: "720px", @@ -67,156 +393,34 @@ const CippStandardDialog = ({ }} > Select a Standard to Add - + handleSearchQueryChange(e.target.value.toLowerCase())} + sx={{ mt: 3, mb: 3 }} + onChange={handleLocalSearchChange} + value={localSearchQuery} + autoComplete="off" /> - - {Object.keys(categories).every( - (category) => filterStandards(categories[category]).length === 0 - ) ? ( - - Search returned no results - - ) : ( - Object.keys(categories).map((category) => - filterStandards(categories[category]).map((standard) => ( - - - {isNewStandard(standard.addedDate) && ( - - )} - - - {standard.label} - - {standard.helpText && ( - <> - - Description: - - - {standard.helpText} - - - )} - - Category: - - - {standard.tag?.filter((tag) => !tag.toLowerCase().includes("impact")).length > - 0 && ( - <> - - Tags: - - - {standard.tag - .filter((tag) => !tag.toLowerCase().includes("impact")) - .map((tag, idx) => ( - - ))} - - - )} - - Impact: - - - {standard.recommendedBy?.length > 0 && ( - <> - - Recommended By: - - - {standard.recommendedBy.join(", ")} - - - )} - {standard.addedDate?.length > 0 && ( - <> - - Date Added: - - - - {standard.addedDate} - - - - )} - - - - {standard.multiple ? ( - handleAddClick(standard.name)} - > - - - ) : ( - handleToggleSingleStandard(standard.name)} - /> - } - label="Add this standard to the template" - /> - )} - - - - )) - ) - )} - + + {isInitialLoading ? ( + + + + ) : processedItems.length === 0 ? ( + + Search returned no results + + ) : ( + + )} - diff --git a/src/components/CippStandards/CippStandardsSideBar.jsx b/src/components/CippStandards/CippStandardsSideBar.jsx index f069bc396ddc..f14945745a04 100644 --- a/src/components/CippStandards/CippStandardsSideBar.jsx +++ b/src/components/CippStandards/CippStandardsSideBar.jsx @@ -75,15 +75,25 @@ const CippStandardsSideBar = ({ useEffect(() => { const stepsStatus = { - step1: !!watchForm.templateName, - step2: watchForm.tenantFilter && watchForm.tenantFilter.length > 0, + step1: !!_.get(watchForm, "templateName"), + step2: _.get(watchForm, "tenantFilter", []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - watchForm.standards && + _.get(watchForm, "standards") && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { - const standardValues = _.get(watchForm, `${standardName}`, {}) ?? {}; - return standardValues?.action; + const standardValues = _.get(watchForm, `${standardName}`, {}); + const standard = selectedStandards[standardName]; + // Check if this standard requires an action + const hasRequiredComponents = + standard?.addedComponent && + standard.addedComponent.some( + (comp) => comp.type !== "switch" && comp.required !== false + ); + const actionRequired = standard?.disabledFeatures !== undefined || hasRequiredComponents; + // Always require an action value which should be an array with at least one element + const actionValue = _.get(standardValues, "action"); + return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); }), }; @@ -91,16 +101,20 @@ const CippStandardsSideBar = ({ setCurrentStep(completedSteps); }, [selectedStandards, watchForm]); + // Create a local reference to the stepsStatus from the latest effect run const stepsStatus = { - step1: !!watchForm.templateName, - step2: watchForm.tenantFilter && watchForm.tenantFilter.length > 0, + step1: !!_.get(watchForm, "templateName"), + step2: _.get(watchForm, "tenantFilter", []).length > 0, step3: Object.keys(selectedStandards).length > 0, step4: - watchForm.standards && + _.get(watchForm, "standards") && Object.keys(selectedStandards).length > 0 && Object.keys(selectedStandards).every((standardName) => { const standardValues = _.get(watchForm, `${standardName}`, {}); - return standardValues?.action; + const standard = selectedStandards[standardName]; + // Always require an action for all standards (must be an array with at least one element) + const actionValue = _.get(standardValues, "action"); + return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); }), }; return ( @@ -134,7 +148,9 @@ const CippStandardsSideBar = ({ required={true} includeGroups={true} /> - {watchForm.tenantFilter?.some((tenant) => tenant.value === "AllTenants" || tenant.type === "Group" ) && ( + {watchForm.tenantFilter?.some( + (tenant) => tenant.value === "AllTenants" || tenant.type === "Group" + ) && ( <> - setTableFilter("", "reset", "")}> + setTableFilter("", "reset", "")}> {api?.url === "/api/ListGraphRequest" && ( diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js index d08c03a7fbf6..92bfae5bef81 100644 --- a/src/components/PrivateRoute.js +++ b/src/components/PrivateRoute.js @@ -27,7 +27,7 @@ export const PrivateRoute = ({ children, routeType }) => { if ( apiRoles?.error?.response?.status === 404 || // API endpoint not found apiRoles?.error?.response?.status === 502 || // Service unavailable - (apiRoles?.isSuccess && !apiRoles?.data?.clientPrincipal) // No client principal data, indicating API might be offline + (apiRoles?.isSuccess && !apiRoles?.data) // No client principal data, indicating API might be offline ) { return ; } @@ -48,7 +48,7 @@ export const PrivateRoute = ({ children, routeType }) => { session?.data?.clientPrincipal?.userDetails !== apiRoles?.data?.clientPrincipal?.userDetails ) { // refetch the profile if the user details are different - refetch(); + apiRoles.refetch(); } if (null !== apiRoles?.data?.clientPrincipal && undefined !== apiRoles?.data) { diff --git a/src/data/alerts.json b/src/data/alerts.json index a691815fa9bc..ef62b6d13373 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -158,5 +158,15 @@ "inputType": "switch", "inputLabel": "Ignore Disabled Apps?", "inputName": "IgnoreDisabledApps" + }, + { + "name": "TERRL", + "label": "Alert when Tenant External Recipient Rate Limit exceeds X %", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Alert % (default: 80)", + "inputName": "TERRLThreshold", + "recommendedRunInterval": "1h", + "description": "Monitors tenant outbound email volume against Microsoft's TERRL limits. Tenant data is updated every hour." } ] diff --git a/src/data/standards.json b/src/data/standards.json index a211c4a2cd13..d70ddafcbd6c 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -38,10 +38,54 @@ "powershellEquivalent": "Set-MsolCompanyContactInformation", "recommendedBy": [] }, + { + "name": "standards.DeployMailContact", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Creates a new mail contact in Exchange Online across all selected tenants. The contact will be visible in the Global Address List.", + "docsDescription": "This standard creates a new mail contact in Exchange Online. Mail contacts are useful for adding external email addresses to your organization's address book. They can be used for distribution lists, shared mailboxes, and other collaboration scenarios.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.DeployMailContact.ExternalEmailAddress", + "label": "External Email Address", + "required": true + }, + { + "type": "textField", + "name": "standards.DeployMailContact.DisplayName", + "label": "Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.DeployMailContact.FirstName", + "label": "First Name", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployMailContact.LastName", + "label": "Last Name", + "required": false + } + ], + "label": "Deploy Mail Contact", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2024-03-19", + "powershellEquivalent": "New-MailContact", + "recommendedBy": [ + "CIPP" + ] + }, { "name": "standards.AuditLog", "cat": "Global Standards", - "tag": ["CIS", "mip_search_auditlog"], + "tag": [ + "CIS", + "mip_search_auditlog" + ], "helpText": "Enables the Unified Audit Log for tracking and auditing activities. Also runs Enable-OrganizationCustomization if necessary.", "addedComponent": [], "label": "Enable the Unified Audit Log", @@ -49,7 +93,10 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Enable-OrganizationCustomization", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.ProfilePhotos", @@ -99,7 +146,9 @@ "remediate": false }, "powershellEquivalent": "Portal only", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.Branding", @@ -161,7 +210,10 @@ { "name": "standards.EnableCustomerLockbox", "cat": "Global Standards", - "tag": ["CIS", "CustomerLockBoxEnabled"], + "tag": [ + "CIS", + "CustomerLockBoxEnabled" + ], "helpText": "Enables Customer Lockbox that offers an approval process for Microsoft support to access organization data", "docsDescription": "Customer Lockbox ensures that Microsoft can't access your content to do service operations without your explicit approval. Customer Lockbox ensures only authorized requests allow access to your organizations data.", "addedComponent": [], @@ -170,7 +222,9 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Set-OrganizationConfig -CustomerLockBoxEnabled $true", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.EnablePronouns", @@ -197,7 +251,9 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Update-MgBetaAdminReportSetting -BodyParameter @{displayConcealedNames = $true}", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.DisableGuestDirectory", @@ -211,7 +267,9 @@ "impactColour": "info", "addedDate": "2022-05-04", "powershellEquivalent": "Set-AzureADMSAuthorizationPolicy -GuestUserRoleId '2af84b1e-32c8-42b7-82bc-daa82404023b'", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.DisableBasicAuthSMTP", @@ -225,12 +283,18 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Set-TransportConfig -SmtpClientAuthenticationDisabled $true", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.ActivityBasedTimeout", "cat": "Global Standards", - "tag": ["CIS", "spo_idle_session_timeout"], + "tag": [ + "CIS", + "spo_idle_session_timeout" + ], "helpText": "Enables and sets Idle session timeout for Microsoft 365 to 1 hour. This policy affects most M365 web apps", "addedComponent": [ { @@ -268,7 +332,9 @@ "impactColour": "warning", "addedDate": "2022-04-13", "powershellEquivalent": "Portal or Graph API", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.AuthMethodsSettings", @@ -404,12 +470,16 @@ "impactColour": "info", "addedDate": "2023-04-25", "powershellEquivalent": "Portal or Graph API", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.PWdisplayAppInformationRequiredState", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Enables the MS authenticator app to display information about the app that is requesting authentication. This displays the application name.", "docsDescription": "Allows users to use Passwordless with Number Matching and adds location information from the last request", "addedComponent": [], @@ -418,7 +488,9 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.allowOTPTokens", @@ -478,7 +550,9 @@ "impactColour": "info", "addedDate": "2022-12-08", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.EnableHardwareOAuth", @@ -538,12 +612,17 @@ "impactColour": "info", "addedDate": "2022-03-15", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.PasswordExpireDisabled", "cat": "Entra (AAD) Standards", - "tag": ["CIS", "PWAgePolicyNew"], + "tag": [ + "CIS", + "PWAgePolicyNew" + ], "helpText": "Disables the expiration of passwords for the tenant by setting the password expiration policy to never expire for any user.", "docsDescription": "Sets passwords to never expire for tenant, recommended to use in conjunction with secure password requirements.", "addedComponent": [], @@ -552,7 +631,10 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Update-MgDomain", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.ExternalMFATrusted", @@ -588,7 +670,9 @@ { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "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": [], @@ -597,12 +681,17 @@ "impactColour": "info", "addedDate": "2022-11-29", "powershellEquivalent": "Update-MgPolicyAuthorizationPolicy", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.EnableAppConsentRequests", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings", "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards", "addedComponent": [ @@ -617,7 +706,9 @@ "impactColour": "info", "addedDate": "2023-11-27", "powershellEquivalent": "Update-MgPolicyAdminConsentRequestPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.NudgeMFA", @@ -674,7 +765,9 @@ { "name": "standards.DisableAppCreation", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Disables the ability for users to create App registrations in the tenant.", "docsDescription": "Disables the ability for users to create applications in Entra. Done to prevent breached accounts from creating an app to maintain access to the tenant, even after the breached account has been secured.", "addedComponent": [], @@ -683,7 +776,10 @@ "impactColour": "info", "addedDate": "2024-03-20", "powershellEquivalent": "Update-MgPolicyAuthorizationPolicy", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.DisableSecurityGroupUsers", @@ -742,12 +838,17 @@ "impactColour": "warning", "addedDate": "2022-10-20", "powershellEquivalent": "Graph API", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.OauthConsent", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Disables users from being able to consent to applications, except for those specified in the field below", "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications.", "addedComponent": [ @@ -763,12 +864,17 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Update-MgPolicyAuthorizationPolicy", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.OauthConsentLowSec", "cat": "Entra (AAD) Standards", - "tag": ["IntegratedApps"], + "tag": [ + "IntegratedApps" + ], "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks.", "docsDescription": "Allows users to consent to applications with low assigned risk.", "label": "Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure)", @@ -821,7 +927,9 @@ { "name": "standards.StaleEntraDevices", "cat": "Entra (AAD) Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "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)", "addedComponent": [ @@ -882,7 +990,9 @@ "impactColour": "danger", "addedDate": "2023-12-18", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.DisableVoice", @@ -896,7 +1006,9 @@ "impactColour": "danger", "addedDate": "2023-12-18", "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.DisableEmail", @@ -982,7 +1094,9 @@ { "name": "standards.OutBoundSpamAlert", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Set the Outbound Spam Alert e-mail address", "docsDescription": "Sets the e-mail address to which outbound spam alerts are sent.", "addedComponent": [ @@ -997,7 +1111,9 @@ "impactColour": "info", "addedDate": "2023-05-03", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.MessageExpiration", @@ -1060,7 +1176,9 @@ "impactColour": "info", "addedDate": "2024-04-26", "powershellEquivalent": "Set-RemoteDomain -Identity 'Default' -TNEFEnabled $false", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.FocusedInbox", @@ -1174,7 +1292,9 @@ { "name": "standards.SpoofWarn", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", "addedComponent": [ @@ -1208,12 +1328,18 @@ "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Set-ExternalInOutlook \u2013Enabled $true or $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.EnableMailTips", "cat": "Exchange Standards", - "tag": ["CIS", "exo_mailtipsenabled"], + "tag": [ + "CIS", + "exo_mailtipsenabled" + ], "helpText": "Enables all MailTips in Outlook. MailTips are the notifications Outlook and Outlook on the web shows when an email you create, meets some requirements", "addedComponent": [ { @@ -1229,7 +1355,10 @@ "impactColour": "info", "addedDate": "2024-01-14", "powershellEquivalent": "Set-OrganizationConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.TeamsMeetingsByDefault", @@ -1279,7 +1408,9 @@ { "name": "standards.RotateDKIM", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Rotate DKIM keys that are 1024 bit to 2048 bit", "addedComponent": [], "label": "Rotate DKIM keys that are 1024 bit to 2048 bit", @@ -1287,12 +1418,17 @@ "impactColour": "info", "addedDate": "2023-03-14", "powershellEquivalent": "Rotate-DkimSigningConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.AddDKIM", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Enables DKIM for all domains that currently support it", "addedComponent": [], "label": "Enables DKIM for all domains that currently support it", @@ -1300,12 +1436,18 @@ "impactColour": "info", "addedDate": "2023-03-14", "powershellEquivalent": "New-DkimSigningConfig and Set-DkimSigningConfig", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.EnableMailboxAuditing", "cat": "Exchange Standards", - "tag": ["CIS", "exo_mailboxaudit"], + "tag": [ + "CIS", + "exo_mailboxaudit" + ], "helpText": "Enables Mailbox auditing for all mailboxes and on tenant level. Disables audit bypass on all mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "docsDescription": "Enables mailbox auditing on tenant level and for all mailboxes. Disables audit bypass on all mailboxes. By default Microsoft does not enable mailbox auditing for Resource Mailboxes, Public Folder Mailboxes and DiscoverySearch Mailboxes. Unified Audit Log needs to be enabled for this standard to function.", "addedComponent": [], @@ -1314,7 +1456,10 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Set-OrganizationConfig -AuditDisabled $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.SendReceiveLimitTenant", @@ -1417,7 +1562,9 @@ { "name": "standards.EXOOutboundSpamLimits", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Configures the outbound spam recipient limits (external per hour, internal per hour, per day) and the action to take when a limit is reached. The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one. ", "docsDescription": "Configures the Exchange Online outbound spam recipient limits for external per hour, internal per hour, and per day, along with the action to take (e.g., BlockUser, Alert) when these limits are exceeded. This helps prevent abuse and manage email flow. Microsoft's recommendations can be found [here.](https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#eop-outbound-spam-policy-settings) The 'Set Outbound Spam Alert e-mail' standard is recommended to configure together with this one.", "addedComponent": [ @@ -1466,12 +1613,18 @@ "impactColour": "info", "addedDate": "2025-05-13", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy", - "recommendedBy": ["CIPP", "CIS"] + "recommendedBy": [ + "CIPP", + "CIS" + ] }, { "name": "standards.DisableExternalCalendarSharing", "cat": "Exchange Standards", - "tag": ["CIS", "exo_individualsharing"], + "tag": [ + "CIS", + "exo_individualsharing" + ], "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", "addedComponent": [], @@ -1480,12 +1633,16 @@ "impactColour": "info", "addedDate": "2024-01-08", "powershellEquivalent": "Get-SharingPolicy | Set-SharingPolicy -Enabled $False", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.AutoAddProxy", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "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.", "addedComponent": [], @@ -1504,7 +1661,10 @@ { "name": "standards.DisableAdditionalStorageProviders", "cat": "Exchange Standards", - "tag": ["CIS", "exo_storageproviderrestricted"], + "tag": [ + "CIS", + "exo_storageproviderrestricted" + ], "helpText": "Disables the ability for users to open files in Outlook on the Web, from other providers such as Box, Dropbox, Facebook, Google Drive, OneDrive Personal, etc.", "docsDescription": "Disables additional storage providers in OWA. This is to prevent users from using personal storage providers like Dropbox, Google Drive, etc. Usually this has little user impact.", "addedComponent": [], @@ -1513,7 +1673,9 @@ "impactColour": "info", "addedDate": "2024-01-17", "powershellEquivalent": "Get-OwaMailboxPolicy | Set-OwaMailboxPolicy -AdditionalStorageProvidersEnabled $False", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.AntiSpamSafeList", @@ -1647,7 +1809,10 @@ { "name": "standards.DisableOutlookAddins", "cat": "Exchange Standards", - "tag": ["CIS", "exo_outlookaddins"], + "tag": [ + "CIS", + "exo_outlookaddins" + ], "helpText": "Disables the ability for users to install add-ins in Outlook. This is to prevent users from installing malicious add-ins.", "docsDescription": "Disables users from being able to install add-ins in Outlook. Only admins are able to approve add-ins for the users. This is done to reduce the threat surface for data exfiltration.", "addedComponent": [], @@ -1656,7 +1821,9 @@ "impactColour": "warning", "addedDate": "2024-02-05", "powershellEquivalent": "Get-ManagementRoleAssignment | Remove-ManagementRoleAssignment", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.SafeSendersDisable", @@ -1674,7 +1841,9 @@ "impactColour": "warning", "addedDate": "2023-10-26", "powershellEquivalent": "Set-MailboxJunkEmailConfiguration", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.DelegateSentItems", @@ -1708,7 +1877,9 @@ "impactColour": "warning", "addedDate": "2022-05-25", "powershellEquivalent": "Set-Mailbox", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.UserSubmissions", @@ -1750,7 +1921,9 @@ { "name": "standards.DisableSharedMailbox", "cat": "Exchange Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Blocks login for all accounts that are marked as a shared mailbox. This is Microsoft best practice to prevent direct logons to shared mailboxes.", "docsDescription": "Shared 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 shared mailboxes. It would be a good idea to review the sign-in reports to establish potential impact.", "addedComponent": [], @@ -1759,12 +1932,19 @@ "impactColour": "warning", "addedDate": "2021-11-16", "powershellEquivalent": "Get-Mailbox & Update-MgUser", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.EXODisableAutoForwarding", "cat": "Exchange Standards", - "tag": ["CIS", "mdo_autoforwardingmode", "mdo_blockmailforward"], + "tag": [ + "CIS", + "mdo_autoforwardingmode", + "mdo_blockmailforward" + ], "helpText": "Disables the ability for users to automatically forward e-mails to external recipients.", "docsDescription": "Disables the ability for users to automatically forward e-mails to external recipients. This is to prevent data exfiltration. Please check if there are any legitimate use cases for this feature before implementing, like forwarding invoices and such.", "addedComponent": [], @@ -1773,7 +1953,10 @@ "impactColour": "danger", "addedDate": "2024-07-26", "powershellEquivalent": "Set-HostedOutboundSpamFilterPolicy -AutoForwardingMode 'Off'", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.RetentionPolicyTag", @@ -1854,7 +2037,11 @@ { "name": "standards.SafeLinksPolicy", "cat": "Defender Standards", - "tag": ["CIS", "mdo_safelinksforemail", "mdo_safelinksforOfficeApps"], + "tag": [ + "CIS", + "mdo_safelinksforemail", + "mdo_safelinksforOfficeApps" + ], "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": [ { @@ -1886,7 +2073,9 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-SafeLinksPolicy or New-SafeLinksPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.AntiPhishPolicy", @@ -2099,12 +2288,19 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-AntiPhishPolicy or New-AntiPhishPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.SafeAttachmentPolicy", "cat": "Defender Standards", - "tag": ["CIS", "mdo_safedocuments", "mdo_commonattachmentsfilter", "mdo_safeattachmentpolicy"], + "tag": [ + "CIS", + "mdo_safedocuments", + "mdo_commonattachmentsfilter", + "mdo_safeattachmentpolicy" + ], "helpText": "This creates a Safe Attachment policy", "addedComponent": [ { @@ -2170,12 +2366,16 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-SafeAttachmentPolicy or New-SafeAttachmentPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.AtpPolicyForO365", "cat": "Defender Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "This creates a Atp policy that enables Defender for Office 365 for SharePoint, OneDrive and Microsoft Teams.", "addedComponent": [ { @@ -2191,7 +2391,9 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-AtpPolicyForO365", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.PhishingSimulations", @@ -2241,7 +2443,12 @@ { "name": "standards.MalwareFilterPolicy", "cat": "Defender Standards", - "tag": ["CIS", "mdo_zapspam", "mdo_zapphish", "mdo_zapmalware"], + "tag": [ + "CIS", + "mdo_zapspam", + "mdo_zapphish", + "mdo_zapmalware" + ], "helpText": "This creates a Malware filter policy that enables the default File filter and Zero-hour auto purge for malware.", "addedComponent": [ { @@ -2327,7 +2534,9 @@ "impactColour": "info", "addedDate": "2024-03-25", "powershellEquivalent": "Set-MalwareFilterPolicy or New-MalwareFilterPolicy", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.PhishSimSpoofIntelligence", @@ -2757,7 +2966,9 @@ "impactColour": "info", "addedDate": "2023-05-19", "powershellEquivalent": "Graph API", - "recommendedBy": ["CIPP"] + "recommendedBy": [ + "CIPP" + ] }, { "name": "standards.intuneBrandingProfile", @@ -3108,7 +3319,9 @@ { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Ensure SharePoint and OneDrive integration with Azure AD B2B is enabled", "addedComponent": [], "label": "Enable SharePoint and OneDrive integration with Azure AD B2B", @@ -3116,12 +3329,16 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EnableAzureADB2BIntegration $true", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.SPDisallowInfectedFiles", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Ensure Office 365 SharePoint infected files are disallowed for download", "addedComponent": [], "label": "Disallow downloading infected files from SharePoint", @@ -3129,7 +3346,10 @@ "impactColour": "info", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -DisallowInfectedFileDownload $true", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.SPDisableLegacyWorkflows", @@ -3147,7 +3367,9 @@ { "name": "standards.SPDirectSharing", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Ensure default link sharing is set to Direct in SharePoint and OneDrive", "addedComponent": [], "label": "Default sharing to Direct users", @@ -3155,12 +3377,17 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType Direct", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.SPExternalUserExpiration", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Ensure guest access to a site or OneDrive will expire automatically", "addedComponent": [ { @@ -3174,12 +3401,16 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -ExternalUserExpireInDays 30 -ExternalUserExpirationRequired $True", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.SPEmailAttestation", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Ensure re-authentication with verification code is restricted", "addedComponent": [ { @@ -3193,7 +3424,10 @@ "impactColour": "warning", "addedDate": "2024-07-09", "powershellEquivalent": "Set-SPOTenant -EmailAttestationRequired $true -EmailAttestationReAuthDays 15", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.DisableAddShortcutsToOneDrive", @@ -3260,7 +3494,10 @@ { "name": "standards.DisableSharePointLegacyAuth", "cat": "SharePoint Standards", - "tag": ["CIS", "spo_legacy_auth"], + "tag": [ + "CIS", + "spo_legacy_auth" + ], "helpText": "Disables the ability to authenticate with SharePoint using legacy authentication methods. Any applications that use legacy authentication will need to be updated to use modern authentication.", "docsDescription": "Disables the ability for users and applications to access SharePoint via legacy basic authentication. This will likely not have any user impact, but will block systems/applications depending on basic auth or the SharePointOnlineCredentials class.", "addedComponent": [], @@ -3269,12 +3506,17 @@ "impactColour": "warning", "addedDate": "2024-02-05", "powershellEquivalent": "Set-SPOTenant -LegacyAuthProtocolsEnabled $false", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.sharingCapability", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Sets the default sharing level for OneDrive and SharePoint. This is a tenant wide setting and overrules any settings set on the site level", "addedComponent": [ { @@ -3307,12 +3549,17 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.DisableReshare", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "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": [], @@ -3321,7 +3568,10 @@ "impactColour": "danger", "addedDate": "2022-06-15", "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", - "recommendedBy": ["CIS", "CIPP"] + "recommendedBy": [ + "CIS", + "CIPP" + ] }, { "name": "standards.DisableUserSiteCreate", @@ -3385,7 +3635,9 @@ { "name": "standards.sharingDomainRestriction", "cat": "SharePoint Standards", - "tag": ["CIS"], + "tag": [ + "CIS" + ], "helpText": "Restricts sharing to only users with the specified domain. This is useful for organizations that only want to share with their own domain.", "addedComponent": [ { @@ -3492,7 +3744,9 @@ "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"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.TeamsEmailIntegration", @@ -3512,7 +3766,29 @@ "impactColour": "info", "addedDate": "2024-07-30", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowEmailIntoChannel $false", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] + }, + { + "name": "standards.TeamsGuestAccess", + "cat": "Teams Standards", + "tag": [], + "helpText": "Allow guest users access to teams.", + "docsDescription": "Allow guest users access to teams. Guest users are users who are not part of your organization but have been invited to collaborate with your organization in Teams. This setting allows you to control whether guest users can access Teams.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.TeamsGuestAccess.AllowGuestUser", + "label": "Allow guest users" + } + ], + "label": "Allow guest users in Teams", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-06-03", + "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGuestUser $true", + "recommendedBy": [] }, { "name": "standards.TeamsExternalFileSharing", @@ -3551,7 +3827,9 @@ "impactColour": "info", "addedDate": "2024-07-28", "powershellEquivalent": "Set-CsTeamsClientConfiguration -AllowGoogleDrive $false -AllowShareFile $false -AllowBox $false -AllowDropBox $false -AllowEgnyte $false", - "recommendedBy": ["CIS"] + "recommendedBy": [ + "CIS" + ] }, { "name": "standards.TeamsEnrollUser", @@ -4142,5 +4420,28 @@ } } ] + }, + { + "name": "standards.MailboxRecipientLimits", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Sets the maximum number of recipients that can be specified in the To, Cc, and Bcc fields of a message for all mailboxes in the tenant.", + "docsDescription": "This standard configures the recipient limits for all mailboxes in the tenant. The recipient limit determines the maximum number of recipients that can be specified in the To, Cc, and Bcc fields of a message. This helps prevent spam and manage email flow.", + "addedComponent": [ + { + "type": "number", + "name": "standards.MailboxRecipientLimits.RecipientLimit", + "label": "Recipient Limit", + "defaultValue": 500 + } + ], + "label": "Set Mailbox Recipient Limits", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-05-28", + "powershellEquivalent": "Set-Mailbox -RecipientLimits", + "recommendedBy": [ + "CIPP" + ] } -] +] \ No newline at end of file diff --git a/src/layouts/index.js b/src/layouts/index.js index 6603b874f2e5..0b2f011034b1 100644 --- a/src/layouts/index.js +++ b/src/layouts/index.js @@ -12,7 +12,7 @@ import { useDispatch } from "react-redux"; import { showToast } from "../store/toasts"; import { Box, Container, Grid } from "@mui/system"; import { CippImageCard } from "../components/CippCards/CippImageCard"; -import Page from "../pages/onboarding"; +import Page from "../pages/onboardingv2"; import { useDialog } from "../hooks/use-dialog"; import { nativeMenuItems } from "/src/layouts/config"; import { keepPreviousData } from "@tanstack/react-query"; diff --git a/src/pages/_app.js b/src/pages/_app.js index 2f0a405111d9..59dd154b372a 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -26,10 +26,11 @@ import { Feedback as FeedbackIcon, AutoStories, Gavel, + Celebration, } from "@mui/icons-material"; import { SvgIcon } from "@mui/material"; import discordIcon from "../../public/discord-mark-blue.svg"; -import React, { useEffect } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { usePathname } from "next/navigation"; import { useRouter } from "next/router"; import { persistQueryClient } from "@tanstack/react-query-persist-client"; @@ -50,9 +51,16 @@ const App = (props) => { const preferredTheme = useMediaPredicate("(prefers-color-scheme: dark)") ? "dark" : "light"; const pathname = usePathname(); const route = useRouter(); + const [_0x8h9i, _0x2j3k] = useState(false); // toRemove const excludeQueryKeys = ["authmeswa"]; + const _0x4f2d = [1772236800, 1772391599]; // toRemove + const _0x2e1f = () => { // toRemove + const _0x1a2b = Date.now() / 1000; // toRemove + return _0x1a2b >= _0x4f2d[0] && _0x1a2b <= _0x4f2d[1]; // toRemove + }; + // 👇 Persist TanStack Query cache to localStorage useEffect(() => { if (typeof window !== "undefined") { @@ -88,7 +96,66 @@ const App = (props) => { } }, []); + useEffect(() => { // toRemove + if (_0x8h9i) { // toRemove + const _0x3c4d = Array.from(document.querySelectorAll('*')).filter(_0x5e6f => { // toRemove + const _0x7g8h = document.querySelector('[aria-label="Navigation SpeedDial"]'); // toRemove + return !_0x7g8h?.contains(_0x5e6f); // toRemove + }); + + _0x3c4d.forEach((_0x9i0j, _0x1k2l) => { // toRemove + const _0x3m4n = Math.random() * 10 - 5; // toRemove + const _0x5o6p = Math.random() * 10 - 5; // toRemove + const _0x7q8r = Math.random() * 10 - 5; // toRemove + const _0x9s0t = Math.random() * 0.5; // toRemove + const _0x1u2v = 0.3 + Math.random() * 0.4; // toRemove + + const _0x3w4x = `_${_0x1k2l}`; // toRemove + const _0x5y6z = document.styleSheets[0]; // toRemove + _0x5y6z.insertRule(` // toRemove + @keyframes ${_0x3w4x} { // toRemove + 0% { transform: translate(0, 0) rotate(0deg); } // toRemove + 25% { transform: translate(${_0x3m4n}px, ${_0x5o6p}px) rotate(${_0x7q8r}deg); } // toRemove + 50% { transform: translate(0, 0) rotate(0deg); } // toRemove + 75% { transform: translate(${-_0x3m4n}px, ${_0x5o6p}px) rotate(${-_0x7q8r}deg); } // toRemove + 100% { transform: translate(0, 0) rotate(0deg); } // toRemove + } + `, _0x5y6z.cssRules.length); // toRemove + + _0x9i0j.style.animation = `${_0x3w4x} ${_0x1u2v}s infinite ${_0x9s0t}s`; // toRemove + }); + + const _0x1a2b = setTimeout(() => { // toRemove + _0x2j3k(false); // toRemove + _0x3c4d.forEach(_0x5e6f => { // toRemove + _0x5e6f.style.animation = ''; // toRemove + }); + const _0x7g8h = document.styleSheets[0]; // toRemove + while (_0x7g8h.cssRules.length > 0) { // toRemove + _0x7g8h.deleteRule(0); // toRemove + } + }, 5000); // toRemove + + return () => { // toRemove + clearTimeout(_0x1a2b); // toRemove + _0x3c4d.forEach(_0x5e6f => { // toRemove + _0x5e6f.style.animation = ''; // toRemove + }); + const _0x7g8h = document.styleSheets[0]; // toRemove + while (_0x7g8h.cssRules.length > 0) { // toRemove + _0x7g8h.deleteRule(0); // toRemove + } + }; + } + }, [_0x8h9i]); // toRemove + const speedDialActions = [ + ...(_0x2e1f() ? [{ // toRemove + id: "_", // toRemove + icon: , // toRemove + name: String.fromCharCode(68, 111, 32, 116, 104, 101, 32, 72, 97, 114, 108, 101, 109, 32, 83, 104, 97, 107, 101, 33), // toRemove + onClick: () => _0x2j3k(true), // toRemove + }] : []), // toRemove { id: "license", icon: , diff --git a/src/pages/cipp/advanced/timers.js b/src/pages/cipp/advanced/timers.js index 823f985f36e1..711def195cc7 100644 --- a/src/pages/cipp/advanced/timers.js +++ b/src/pages/cipp/advanced/timers.js @@ -80,6 +80,7 @@ const Page = () => { url: apiUrl, data: { FunctionName: "Command", Parameters: "Parameters" }, confirmText: "Do you want to run this task now?", + allowResubmit: true, }, ]} /> diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 9f938ee29e7c..9b8faac78fe7 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -1,10 +1,21 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { useSettings } from "/src/hooks/use-settings"; import { useRouter } from "next/router"; -import { ApiGetCall } from "/src/api/ApiCall"; +import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; import CippFormSkeleton from "/src/components/CippFormPages/CippFormSkeleton"; import CalendarIcon from "@heroicons/react/24/outline/CalendarIcon"; -import { Check, Error, Mail, Fingerprint, Launch, Delete, Star, Close } from "@mui/icons-material"; +import { + Check, + Error, + Mail, + Fingerprint, + Launch, + Delete, + Star, + CalendarToday, + AlternateEmail, + PersonAdd, +} from "@mui/icons-material"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; import tabOptions from "./tabOptions"; import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; @@ -16,26 +27,28 @@ import { CippExchangeInfoCard } from "../../../../../components/CippCards/CippEx import { useEffect, useState } from "react"; import CippExchangeSettingsForm from "../../../../../components/CippFormPages/CippExchangeSettingsForm"; import { useForm } from "react-hook-form"; -import { Alert, Button, Collapse, CircularProgress, Typography, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton } from "@mui/material"; +import { Alert, Button, Collapse, CircularProgress, Typography } from "@mui/material"; import { CippApiResults } from "../../../../../components/CippComponents/CippApiResults"; -import { Block, PlayArrow } from "@mui/icons-material"; +import { Block, PlayArrow, Add } from "@mui/icons-material"; import { CippPropertyListCard } from "../../../../../components/CippCards/CippPropertyListCard"; import { getCippTranslation } from "../../../../../utils/get-cipp-translation"; import { getCippFormatting } from "../../../../../utils/get-cipp-formatting"; import CippExchangeActions from "../../../../../components/CippComponents/CippExchangeActions"; import { CippApiDialog } from "../../../../../components/CippComponents/CippApiDialog"; import { useDialog } from "../../../../../hooks/use-dialog"; +import CippAliasDialog from "../../../../../components/CippComponents/CippAliasDialog"; +import CippMailboxPermissionsDialog from "../../../../../components/CippComponents/CippMailboxPermissionsDialog"; +import CippCalendarPermissionsDialog from "../../../../../components/CippComponents/CippCalendarPermissionsDialog"; const Page = () => { const userSettingsDefaults = useSettings(); const [waiting, setWaiting] = useState(false); const [showDetails, setShowDetails] = useState(false); const [actionData, setActionData] = useState({ ready: false }); - const [showAddAliasDialog, setShowAddAliasDialog] = useState(false); - const [newAliases, setNewAliases] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitResult, setSubmitResult] = useState(null); const createDialog = useDialog(); + const aliasDialog = useDialog(); + const permissionsDialog = useDialog(); + const calendarPermissionsDialog = useDialog(); const router = useRouter(); const { userId } = router.query; @@ -56,6 +69,18 @@ const Page = () => { waiting: waiting, }); + const usersList = ApiGetCall({ + url: "/api/ListGraphRequest", + data: { + Endpoint: `users`, + tenantFilter: userSettingsDefaults.currentTenant, + $select: "id,displayName,userPrincipalName,mail", + noPagination: true, + $top: 999, + }, + queryKey: `UserNames-${userSettingsDefaults.currentTenant}`, + }); + const oooRequest = ApiGetCall({ url: `/api/ListOoO?UserId=${userId}&tenantFilter=${userSettingsDefaults.currentTenant}`, queryKey: `ooo-${userId}`, @@ -74,6 +99,99 @@ const Page = () => { waiting: waiting, }); + // Define API configurations for the dialogs + const aliasApiConfig = { + type: "POST", + url: "/api/SetUserAliases", + relatedQueryKeys: `ListUsers-${userId}`, + confirmText: "Add the specified proxy addresses to this user?", + customDataformatter: (row, action, formData) => { + return { + id: userId, + tenantFilter: userSettingsDefaults.currentTenant, + AddedAliases: formData?.AddedAliases?.join(",") || "", + userPrincipalName: graphUserRequest?.data?.[0]?.userPrincipalName, + }; + }, + }; + + const permissionsApiConfig = { + type: "POST", + url: "/api/ExecModifyMBPerms", + relatedQueryKeys: `Mailbox-${userId}`, + confirmText: "Add the specified permissions to this mailbox?", + customDataformatter: (row, action, data) => { + const permissions = []; + const { permissions: permissionValues } = data; + const autoMap = data.autoMap === undefined ? true : data.autoMap; + + // Build permissions array based on form values + if (permissionValues?.AddFullAccess) { + permissions.push({ + UserID: permissionValues.AddFullAccess, + PermissionLevel: "FullAccess", + Modification: "Add", + AutoMap: autoMap, + }); + } + if (permissionValues?.AddSendAs) { + permissions.push({ + UserID: permissionValues.AddSendAs, + PermissionLevel: "SendAs", + Modification: "Add", + }); + } + if (permissionValues?.AddSendOnBehalf) { + permissions.push({ + UserID: permissionValues.AddSendOnBehalf, + PermissionLevel: "SendOnBehalf", + Modification: "Add", + }); + } + + return { + userID: graphUserRequest.data?.[0]?.userPrincipalName, + tenantFilter: userSettingsDefaults.currentTenant, + permissions: permissions, + }; + }, + }; + + const calendarPermissionsApiConfig = { + type: "POST", + url: "/api/ExecModifyCalPerms", + relatedQueryKeys: `CalendarPermissions-${userId}`, + confirmText: "Add the specified permissions to this calendar?", + customDataformatter: (row, action, data) => { + if (!data.UserToGetPermissions || !data.Permissions) return null; + + // Build permission object dynamically + const permission = { + UserID: data.UserToGetPermissions, + PermissionLevel: data.Permissions, + Modification: "Add", + }; + + if (data.CanViewPrivateItems) { + permission.CanViewPrivateItems = true; + } + + return { + userID: graphUserRequest.data?.[0]?.userPrincipalName, + tenantFilter: userSettingsDefaults.currentTenant, + permissions: [permission], + }; + }, + }; + + // This effect is no longer needed since we use CippApiDialog for form handling + + useEffect(() => { + if (permissionsDialog.open) { + usersList.refetch(); + } + }, [permissionsDialog.open]); + useEffect(() => { if (oooRequest.isSuccess) { formControl.setValue("ooo.ExternalMessage", oooRequest.data?.ExternalMessage); @@ -142,6 +260,45 @@ const Page = () => { const data = userRequest.data?.[0]; + const mailboxPermissionActions = [ + { + label: "Remove Permission", + type: "POST", + icon: , + url: "/api/ExecModifyMBPerms", + customDataformatter: (row, action, formData) => { + // build permissions + var permissions = []; + // if the row is an array, iterate through it + if (Array.isArray(row)) { + row.forEach((item) => { + permissions.push({ + UserID: item.User, + PermissionLevel: item.AccessRights, + Modification: "Remove", + }); + }); + } else { + // if it's a single object, just push it + permissions.push({ + UserID: row.User, + PermissionLevel: row.AccessRights, + Modification: "Remove", + }); + } + + return { + userID: graphUserRequest.data?.[0]?.userPrincipalName, + tenantFilter: userSettingsDefaults.currentTenant, + permissions: permissions, + }; + }, + confirmText: "Are you sure you want to remove this permission?", + multiPost: false, + relatedQueryKeys: `Mailbox-${userId}`, + }, + ]; + const permissions = [ { id: 1, @@ -154,18 +311,58 @@ const Page = () => { ), }, - text: "Current mailbox permissions", + text: "Mailbox Permissions", subtext: userRequest.data?.[0]?.Permissions?.length !== 0 ? "Other users have access to this mailbox" : "No other users have access to this mailbox", statusColor: "green.main", - //map each of the permissions to a label/value pair, where the label is the user's name and the value is the permission level - propertyItems: - userRequest.data?.[0]?.Permissions?.map((permission) => ({ - label: permission.User, - value: permission.AccessRights, - })) || [], + cardLabelBoxActions: ( + + ), + table: { + title: "Mailbox Permissions", + hideTitle: true, + data: + userRequest.data?.[0]?.Permissions?.map((permission) => ({ + User: permission.User, + AccessRights: permission.AccessRights, + _raw: permission, + })) || [], + refreshFunction: () => userRequest.refetch(), + isFetching: userRequest.isFetching, + simpleColumns: ["User", "AccessRights"], + actions: mailboxPermissionActions, + offCanvas: { + children: (data) => { + return ( + + ); + }, + }, + }, }, ]; @@ -181,17 +378,124 @@ const Page = () => { ), }, - text: "Current Calendar permissions", - subtext: calPermissions.data?.length - ? "Other users have access to this users calendar" - : "No other users have access to this users calendar", + text: "Calendar permissions", + subtext: + calPermissions.data?.length !== 0 + ? "Other users have access to this calendar" + : "No other users have access to this calendar", statusColor: "green.main", - //map each of the permissions to a label/value pair, where the label is the user's name and the value is the permission level - propertyItems: - calPermissions.data?.map((permission) => ({ - label: `${permission.User} - ${permission.FolderName}`, - value: permission.AccessRights.join(", "), - })) || [], + cardLabelBoxActions: ( + + ), + table: { + title: "Calendar Permissions", + hideTitle: true, + data: + calPermissions.data?.map((permission) => ({ + User: permission.User, + AccessRights: permission.AccessRights.join(", "), + FolderName: permission.FolderName, + _raw: permission, + })) || [], + refreshFunction: () => calPermissions.refetch(), + isFetching: calPermissions.isFetching, + simpleColumns: ["User", "AccessRights", "FolderName"], + actions: [ + { + label: "Remove Permission", + type: "POST", + icon: , + url: "/api/ExecModifyCalPerms", + customDataformatter: (row, action, formData) => { + // build permissions + var permissions = []; + // if the row is an array, iterate through it + if (Array.isArray(row)) { + row.forEach((item) => { + permissions.push({ + UserID: item.User, + PermissionLevel: item.AccessRights, + FolderName: item.FolderName, + Modification: "Remove", + }); + }); + } else { + // if it's a single object, just push it + permissions.push({ + UserID: row.User, + PermissionLevel: row.AccessRights, + FolderName: row.FolderName, + Modification: "Remove", + }); + } + return { + userID: graphUserRequest.data?.[0]?.userPrincipalName, + tenantFilter: userSettingsDefaults.currentTenant, + permissions: permissions, + }; + }, + confirmText: "Are you sure you want to remove this calendar permission?", + multiPost: false, + relatedQueryKeys: `CalendarPermissions-${userId}`, + condition: (row) => row.User !== "Default" && row.User == "Anonymous", + }, + ], + offCanvas: { + children: (data) => { + return ( + , + url: "/api/ExecModifyCalPerms", + data: { + userID: graphUserRequest.data?.[0]?.userPrincipalName, + tenantFilter: userSettingsDefaults.currentTenant, + permissions: [ + { + UserID: data.User, + PermissionLevel: data.AccessRights, + FolderName: data.FolderName, + Modification: "Remove", + }, + ], + }, + confirmText: "Are you sure you want to remove this calendar permission?", + multiPost: false, + relatedQueryKeys: `CalendarPermissions-${userId}`, + }, + ]} + /> + ); + }, + }, + }, }, ]; @@ -326,44 +630,7 @@ const Page = () => { }, ]; - const handleAddAliases = () => { - const aliases = newAliases - .split('\n') - .map(alias => alias.trim()) - .filter(alias => alias); - if (aliases.length > 0) { - setIsSubmitting(true); - setSubmitResult(null); - fetch('/api/SetUserAliases', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - id: userId, - tenantFilter: userSettingsDefaults.currentTenant, - AddedAliases: aliases.join(','), - userPrincipalName: graphUserRequest.data?.[0]?.userPrincipalName, - }), - }) - .then(response => response.json()) - .then(data => { - setSubmitResult({ success: true, message: 'Aliases added successfully' }); - graphUserRequest.refetch(); - setTimeout(() => { - setShowAddAliasDialog(false); - setNewAliases(''); - setSubmitResult(null); - }, 1500); - }) - .catch(error => { - setSubmitResult({ success: false, message: 'Failed to add aliases' }); - }) - .finally(() => { - setIsSubmitting(false); - }); - } - }; + // Proxy address actions implementations are handled by the CippAliasDialog component const proxyAddressesCard = [ { @@ -377,18 +644,31 @@ const Page = () => { ), }, - text: "Current Proxy Addresses", - subtext: graphUserRequest.data?.[0]?.proxyAddresses?.length > 1 - ? "Proxy addresses are configured for this user" - : "No proxy addresses configured for this user", + text: "Proxy Addresses", + subtext: + graphUserRequest.data?.[0]?.proxyAddresses?.length > 1 + ? "Proxy addresses are configured for this user" + : "No proxy addresses configured for this user", statusColor: "green.main", + cardLabelBoxActions: ( + + ), table: { title: "Proxy Addresses", hideTitle: true, - data: graphUserRequest.data?.[0]?.proxyAddresses?.map(address => ({ - Address: address, - Type: address.startsWith('SMTP:') ? 'Primary' : 'Alias', - })) || [], + data: + graphUserRequest.data?.[0]?.proxyAddresses?.map((address) => ({ + Address: address, + Type: address.startsWith("SMTP:") ? "Primary" : "Alias", + })) || [], refreshFunction: () => graphUserRequest.refetch(), isFetching: graphUserRequest.isFetching, simpleColumns: ["Address", "Type"], @@ -415,22 +695,13 @@ const Page = () => { }, }, }, - children: ( - - - - ), }, ]; + // These API request objects are no longer needed as they're handled by CippApiDialog + + // Calendar permissions dialog functionality is now handled by the CippCalendarPermissionsDialog component + return ( { sx={{ flexGrow: 1, py: 4, + mr: 2, }} > @@ -492,22 +764,22 @@ const Page = () => { { row={actionData.data} /> )} - setShowAddAliasDialog(false)} - maxWidth="sm" - fullWidth + - - - Add Proxy Addresses - setShowAddAliasDialog(false)} size="small"> - - - - - - - setNewAliases(e.target.value)} - placeholder="One alias per line" - variant="outlined" - disabled={isSubmitting} - /> - {submitResult && ( - - {submitResult.message} - - )} - - - - - - - + {({ formHook }) => } + + + + {({ formHook }) => ( + ({ + value: user.userPrincipalName, + label: `${user.displayName} (${user.userPrincipalName})`, + })) || [] + } + /> + )} + + + + {({ formHook }) => } + ); }; diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index be33d8615a31..87a4c662c54d 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -63,7 +63,7 @@ const AlertWizard = () => { { label: "Email", value: "Email" }, { label: "PSA", value: "PSA" }, ]; - const actionstoTake = [ + const actionsToTake = [ //{ value: 'cippcommand', label: 'Execute a CIPP Command' }, { value: "becremediate", label: "Execute a BEC Remediate" }, { value: "disableuser", label: "Disable the user in the log entry" }, @@ -523,7 +523,7 @@ const AlertWizard = () => { formControl={formControl} multiple={true} creatable={false} - options={actionstoTake} + options={actionsToTake} /> @@ -556,6 +556,9 @@ const AlertWizard = () => { multiple={false} formControl={formControl} label="Included Tenants for alert" + validators={{ + required: { value: true, message: "This field is required" }, + }} /> { icon: , data: roleTemplates.data?.pages - ?.map((page) => page?.Results.length) + ?.map((page) => page?.Results?.length) .reduce((a, b) => a + b, 0) ?? 0, name: "Role Templates", }, diff --git a/src/pages/tenant/standards/compare/index.js b/src/pages/tenant/standards/compare/index.js index b26632b4befd..c64ff14a4bbb 100644 --- a/src/pages/tenant/standards/compare/index.js +++ b/src/pages/tenant/standards/compare/index.js @@ -79,7 +79,7 @@ const Page = () => { url: "/api/ListStandardsCompare", data: { TemplateId: templateId, - CompareTenantId: formControl.watch("compareTenantId"), + tenantFilter: currentTenant, CompareToStandard: true, // Always compare to standard, even in tenant comparison mode }, queryKey: `ListStandardsCompare-${templateId}-${ diff --git a/src/pages/tenant/standards/template.jsx b/src/pages/tenant/standards/template.jsx index 04c8b9b9c906..66bb78c9e842 100644 --- a/src/pages/tenant/standards/template.jsx +++ b/src/pages/tenant/standards/template.jsx @@ -1,34 +1,116 @@ import { Box, Button, Container, Stack, Typography, SvgIcon, Skeleton } from "@mui/material"; import { Grid } from "@mui/system"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { useRouter } from "next/router"; -import { Add } from "@mui/icons-material"; -import { useEffect, useState } from "react"; +import { Add, SaveRounded } from "@mui/icons-material"; +import { useEffect, useState, useCallback, useMemo, useRef, lazy, Suspense } from "react"; import standards from "/src/data/standards"; import CippStandardAccordion from "../../../components/CippStandards/CippStandardAccordion"; -import CippStandardDialog from "../../../components/CippStandards/CippStandardDialog"; +// Lazy load the dialog to improve initial page load performance +const CippStandardDialog = lazy(() => + import("../../../components/CippStandards/CippStandardDialog") +); import CippStandardsSideBar from "../../../components/CippStandards/CippStandardsSideBar"; import { ArrowLeftIcon } from "@mui/x-date-pickers"; -import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { useDialog } from "../../../hooks/use-dialog"; import { ApiGetCall } from "../../../api/ApiCall"; +import _ from "lodash"; const Page = () => { const router = useRouter(); const [editMode, setEditMode] = useState(false); const formControl = useForm({ mode: "onBlur" }); + const { formState } = formControl; const [dialogOpen, setDialogOpen] = useState(false); const [expanded, setExpanded] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [selectedStandards, setSelectedStandards] = useState({}); const [updatedAt, setUpdatedAt] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const initialStandardsRef = useRef({}); + + // Watch form values to check valid configuration + const watchForm = useWatch({ control: formControl.control }); + const existingTemplate = ApiGetCall({ url: `/api/listStandardTemplates`, data: { id: router.query.id }, queryKey: `listStandardTemplates-${router.query.id}`, waiting: editMode, }); + + // Check if the template configuration is valid and update currentStep + useEffect(() => { + const stepsStatus = { + step1: !!_.get(watchForm, "templateName"), + step2: _.get(watchForm, "tenantFilter", []).length > 0, + step3: Object.keys(selectedStandards).length > 0, + step4: + _.get(watchForm, "standards") && + Object.keys(selectedStandards).length > 0 && + Object.keys(selectedStandards).every((standardName) => { + const standardValues = _.get(watchForm, standardName, {}); + // Always require an action value which should be an array with at least one element + const actionValue = _.get(standardValues, "action"); + return actionValue && (!Array.isArray(actionValue) || actionValue.length > 0); + }), + }; + + const completedSteps = Object.values(stepsStatus).filter(Boolean).length; + setCurrentStep(completedSteps); + }, [selectedStandards, watchForm]); + + // Handle route change events + const handleRouteChange = useCallback( + (url) => { + if (hasUnsavedChanges) { + const confirmLeave = window.confirm( + "You have unsaved changes. Are you sure you want to leave this page?" + ); + if (!confirmLeave) { + router.events.emit("routeChangeError"); + throw "Route change was aborted"; + } + } + }, + [hasUnsavedChanges, router] + ); + + // Handle browser back/forward navigation or tab close + useEffect(() => { + const handleBeforeUnload = (e) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = "You have unsaved changes. Are you sure you want to leave this page?"; + return e.returnValue; + } + }; + + // Add event listeners + window.addEventListener("beforeunload", handleBeforeUnload); + router.events.on("routeChangeStart", handleRouteChange); + + // Remove event listeners on cleanup + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + router.events.off("routeChangeStart", handleRouteChange); + }; + }, [hasUnsavedChanges, handleRouteChange, router.events]); + + // Track form changes + useEffect(() => { + if ( + formState.isDirty || + JSON.stringify(selectedStandards) !== JSON.stringify(initialStandardsRef.current) + ) { + setHasUnsavedChanges(true); + } else { + setHasUnsavedChanges(false); + } + }, [formState.isDirty, selectedStandards]); + useEffect(() => { if (router.query.id) { setEditMode(true); @@ -71,30 +153,40 @@ const Page = () => { }); setSelectedStandards(transformedStandards); + // Store initial state for change detection + initialStandardsRef.current = { ...transformedStandards }; + setHasUnsavedChanges(false); } }, [existingTemplate.isSuccess, router]); - const categories = standards.reduce((acc, standard) => { - const { cat } = standard; - if (!acc[cat]) { - acc[cat] = []; - } - acc[cat].push(standard); - return acc; - }, {}); + // Memoize categories to avoid unnecessary recalculations + const categories = useMemo(() => { + return standards.reduce((acc, standard) => { + const { cat } = standard; + if (!acc[cat]) { + acc[cat] = []; + } + acc[cat].push(standard); + return acc; + }, {}); + }, []); + + const handleOpenDialog = useCallback(() => { + setDialogOpen(true); + }, []); - const handleOpenDialog = () => setDialogOpen(true); - const handleCloseDialog = () => { + const handleCloseDialog = useCallback(() => { setDialogOpen(false); setSearchQuery(""); - }; + }, []); const filterStandards = (standardsList) => standardsList.filter( (standard) => - standard.label.toLowerCase().includes(searchQuery) || - standard.helpText.toLowerCase().includes(searchQuery) || - (standard.tag && standard.tag.some((tag) => tag.toLowerCase().includes(searchQuery))) + standard.label.toLowerCase().includes(searchQuery.toLowerCase()) || + standard.helpText.toLowerCase().includes(searchQuery.toLowerCase()) || + (standard.tag && + standard.tag.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))) ); const handleToggleStandard = (standardName) => { @@ -146,15 +238,22 @@ const Page = () => { setExpanded((prev) => (prev === standardName ? null : standardName)); }; - const actions = [ - { - label: "Save Template", - handler: () => createDialog.handleOpen(), - icon: , - }, - ]; const createDialog = useDialog(); + // Save action that will open the create dialog + const handleSave = () => { + createDialog.handleOpen(); + // Will be set to false after successful save in the dialog component + }; + + // Determine if save button should be disabled based on configuration + const isSaveDisabled = + !_.get(watchForm, "tenantFilter") || + !_.get(watchForm, "tenantFilter").length || + currentStep < 3; + + const actions = []; + const steps = [ "Set a name for the Template", "Assigned Template to Tenants", @@ -162,6 +261,19 @@ const Page = () => { "Configured all Standards", ]; + const handleSafeNavigation = (url) => { + if (hasUnsavedChanges) { + const confirmLeave = window.confirm( + "You have unsaved changes. Are you sure you want to leave this page?" + ); + if (confirmLeave) { + router.push(url); + } + } else { + router.push(url); + } + }; + return ( @@ -169,7 +281,13 @@ const Page = () => { + + + + @@ -212,6 +341,7 @@ const Page = () => { selectedStandards={selectedStandards} edit={editMode} updatedAt={updatedAt} + onSaveSuccess={() => setHasUnsavedChanges(false)} /> @@ -235,16 +365,21 @@ const Page = () => { - + {/* Only render the dialog when it's needed */} + {dialogOpen && ( + }> + + + )} ); diff --git a/yarn.lock b/yarn.lock index d809e0a29713..3c47f75cfe6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6096,6 +6096,11 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" +react-virtuoso@^4.12.8: + version "4.12.8" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.12.8.tgz#db1dbba617f91c1dcd760aa90e09ef991e65a356" + integrity sha512-NMMKfDBr/+xZZqCQF3tN1SZsh6FwOJkYgThlfnsPLkaEhdyQo0EuWUzu3ix6qjnI7rYwJhMwRGoJBi+aiDfGsA== + react-window@^1.8.10: version "1.8.11" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.11.tgz#a857b48fa85bd77042d59cc460964ff2e0648525"