diff --git a/generate-placeholders.js b/generate-placeholders.js index 34e28fb31ccf..2b34888fca7a 100644 --- a/generate-placeholders.js +++ b/generate-placeholders.js @@ -83,7 +83,6 @@ const pages = [ { title: "Profiles", path: "/endpoint/autopilot/list-profiles" }, { title: "Add Profile", path: "/endpoint/autopilot/add-profile" }, { title: "Status Pages", path: "/endpoint/autopilot/list-status-pages" }, - { title: "Add Status Page", path: "/endpoint/autopilot/add-status-page" }, { title: "Devices", path: "/endpoint/MEM/devices" }, { title: "Configuration Policies", path: "/endpoint/MEM/list-policies" }, { title: "Compliance Policies", path: "/endpoint/MEM/list-compliance-policies" }, diff --git a/public/version.json b/public/version.json index dc8317517d9b..711bdd5d11ca 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "8.3.1" + "version": "8.3.2" } \ No newline at end of file diff --git a/src/components/CippCards/CippExchangeInfoCard.jsx b/src/components/CippCards/CippExchangeInfoCard.jsx index df22ef79262f..9a5dfb574ae7 100644 --- a/src/components/CippCards/CippExchangeInfoCard.jsx +++ b/src/components/CippCards/CippExchangeInfoCard.jsx @@ -101,6 +101,14 @@ export const CippExchangeInfoCard = (props) => { {getCippFormatting(exchangeData?.BlockedForSpam, "BlockedForSpam")} + + + Retention Policy: + + + {getCippFormatting(exchangeData?.RetentionPolicy, "RetentionPolicy")} + + ) } diff --git a/src/components/CippCards/CippStandardsDialog.jsx b/src/components/CippCards/CippStandardsDialog.jsx index 07e362fe955c..5e3c2cc879d8 100644 --- a/src/components/CippCards/CippStandardsDialog.jsx +++ b/src/components/CippCards/CippStandardsDialog.jsx @@ -50,6 +50,8 @@ const getCategoryIcon = (category) => { return ; case "Intune Standards": return ; + case "Templates": + return ; default: return ; } @@ -101,19 +103,38 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan (excludedTenantsArr.length === 0 || !excludedTenantsArr.some((et) => et.value === currentTenant)); - return tenantInFilter || allTenantsTemplate; + const isApplicable = tenantInFilter || allTenantsTemplate; + + return isApplicable; }); // Combine standards from all applicable templates const combinedStandards = {}; for (const template of applicableTemplates) { for (const [standardKey, standardValue] of Object.entries(template.standards)) { - combinedStandards[standardKey] = standardValue; + if (combinedStandards[standardKey]) { + // If the standard already exists, we need to merge it + const existing = combinedStandards[standardKey]; + const incoming = standardValue; + + // If both are arrays (like IntuneTemplate, ConditionalAccessTemplate), concatenate them + if (Array.isArray(existing) && Array.isArray(incoming)) { + combinedStandards[standardKey] = [...existing, ...incoming]; + } + // If one is array and other is not, or both are objects, keep the last one (existing behavior) + else { + combinedStandards[standardKey] = standardValue; + } + } else { + combinedStandards[standardKey] = standardValue; + } } } // Group standards by category const standardsByCategory = {}; + let totalStandardsCount = 0; + Object.entries(combinedStandards).forEach(([standardKey, standardConfig]) => { const standardInfo = standards.find((s) => s.name === `standards.${standardKey}`); if (standardInfo) { @@ -126,6 +147,13 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan config: standardConfig, info: standardInfo, }); + + // Count template instances separately + if (Array.isArray(standardConfig) && standardConfig.length > 0) { + totalStandardsCount += standardConfig.length; + } else { + totalStandardsCount += 1; + } } }); @@ -167,143 +195,333 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan Total templates applied: {applicableTemplates.length} | Total - standards: {Object.keys(combinedStandards).length} + standards: {totalStandardsCount} - {Object.entries(standardsByCategory).map(([category, categoryStandards], idx) => ( - `1px solid ${theme.palette.divider}`, - "&:before": { display: "none" }, - }} - > - } - aria-controls={`${category}-content`} - id={`${category}-header`} + {Object.entries(standardsByCategory).map(([category, categoryStandards], idx) => { + // Calculate the actual count of standards in this category (counting template instances) + const categoryCount = categoryStandards.reduce((count, { config }) => { + if (Array.isArray(config) && config.length > 0) { + return count + config.length; + } + return count + 1; + }, 0); + + return ( + `1px solid ${theme.palette.divider}`, + "&:before": { display: "none" }, }} > - - {getCategoryIcon(category)} - - {category} - - - - - - - {categoryStandards.map(({ key, config, info }) => ( - - - - - - - {info.label} - - - {info.helpText} - - - - - {info.tag && info.tag.length > 0 && ( - - )} - - - - Actions: - - - {config.action && Array.isArray(config.action) ? ( - config.action.map((action, index) => ( + } + aria-controls={`${category}-content`} + id={`${category}-header`} + sx={{ + minHeight: 48, + "& .MuiAccordionSummary-content": { alignItems: "center", m: 0 }, + }} + > + + {getCategoryIcon(category)} + + {category} + + + + + + + {categoryStandards.map(({ key, config, info }) => { + // Handle template arrays by rendering each template as a separate card + if (Array.isArray(config) && config.length > 0) { + return config.map((templateItem, templateIndex) => ( + + + + + + + {info.label} {config.length > 1 && `(${templateIndex + 1})`} + + + {info.helpText} + + + - )) - ) : ( - - No actions configured - - )} - - - - {info.addedComponent && info.addedComponent.length > 0 && ( - - - Fields: - - - {info.addedComponent.map((component, index) => { - const componentValue = _.get(config, component.name); - const displayValue = - componentValue?.label || componentValue || "N/A"; - return ( - + {info.tag && info.tag.length > 0 && ( + + )} + + + + Actions: + + + {templateItem.action && Array.isArray(templateItem.action) ? ( + templateItem.action.map((action, actionIndex) => ( + + )) + ) : ( - {component.label || component.name}: + No actions configured + )} + + + + {info.addedComponent && info.addedComponent.length > 0 && ( + + + Fields: + + + {info.addedComponent.map((component, componentIndex) => { + const value = _.get(templateItem, component.name); + let displayValue = "N/A"; + + if (value) { + if (typeof value === "object" && value !== null) { + displayValue = + value.label || value.value || JSON.stringify(value); + } else { + displayValue = String(value); + } + } + + return ( + + + {component.label || component.name}: + + + + ); + })} + + + )} + + + + + )); + } + + // Handle regular standards (non-template arrays) + return ( + + + + + + + {info.label} + + + {info.helpText} + + + + + {info.tag && info.tag.length > 0 && ( + + )} + + + + Actions: + + + {config.action && Array.isArray(config.action) ? ( + config.action.map((action, index) => ( - - ); - })} - - - )} - - - - - ))} - - - - ))} + )) + ) : ( + + No actions configured + + )} + + + + {info.addedComponent && info.addedComponent.length > 0 && ( + + + Fields: + + + {info.addedComponent.map((component, index) => { + let componentValue; + let displayValue = "N/A"; + + // Handle regular standards and nested standards structures + let extractedValue = null; + + // Try direct access first + componentValue = _.get(config, component.name); + + // If direct access fails and component name contains dots (nested structure) + if ( + (componentValue === undefined || + componentValue === null) && + component.name.includes(".") + ) { + const pathParts = component.name.split("."); + + // Handle structures like: standards.AuthMethodsSettings.ReportSuspiciousActivity + if (pathParts[0] === "standards" && config.standards) { + // Remove 'standards.' prefix and try to find the value in config.standards + const nestedPath = pathParts.slice(1).join("."); + extractedValue = _.get(config.standards, nestedPath); + + // If still not found, try alternative nested structures + // Some standards have double nesting like: config.standards.StandardName.fieldName + if ( + (extractedValue === undefined || + extractedValue === null) && + pathParts.length >= 3 + ) { + const standardName = pathParts[1]; + const fieldPath = pathParts.slice(2).join("."); + extractedValue = _.get( + config.standards, + `${standardName}.${fieldPath}` + ); + } + } + } else { + extractedValue = componentValue; + } + + if (extractedValue) { + if (Array.isArray(extractedValue)) { + // Handle array of objects + const arrayValues = extractedValue.map((item) => { + if (typeof item === "object" && item !== null) { + return ( + item.label || item.value || JSON.stringify(item) + ); + } + return String(item); + }); + displayValue = arrayValues.join(", "); + } else if ( + typeof extractedValue === "object" && + extractedValue !== null + ) { + if (extractedValue.label) { + displayValue = extractedValue.label; + } else if (extractedValue.value) { + displayValue = extractedValue.value; + } else { + displayValue = JSON.stringify(extractedValue); + } + } else { + displayValue = String(extractedValue); + } + } + + return ( + + + {component.label || component.name}: + + + + ); + })} + + + )} + + + + + ); + })} + + + + ); + })} {Object.keys(standardsByCategory).length === 0 && ( diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 106b3f0f646d..6ac86f53d4ba 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -6,7 +6,7 @@ import { TextField, IconButton, } from "@mui/material"; -import { useEffect, useState, useMemo, useCallback } from "react"; +import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useSettings } from "../../hooks/use-settings"; import { getCippError } from "../../utils/get-cipp-error"; import { ApiGetCallWithPagination } from "../../api/ApiCall"; @@ -78,6 +78,7 @@ export const CippAutoComplete = (props) => { const [usedOptions, setUsedOptions] = useState(options); const [getRequestInfo, setGetRequestInfo] = useState({ url: "", waiting: false, queryKey: "" }); + const hasPreselectedRef = useRef(false); const filter = createFilterOptions({ stringify: (option) => JSON.stringify(option), }); @@ -207,15 +208,32 @@ export const CippAutoComplete = (props) => { return finalOptions; }, [api, usedOptions, options, removeOptions, sortOptions]); - // Dedicated effect for handling preselected value + // Dedicated effect for handling preselected value - only runs once useEffect(() => { - if (preselectedValue && !defaultValue && !value && memoizedOptions.length > 0) { - const preselectedOption = memoizedOptions.find((option) => option.value === preselectedValue); + if (preselectedValue && memoizedOptions.length > 0 && !hasPreselectedRef.current) { + // Check if we should skip preselection due to existing defaultValue + const hasDefaultValue = + defaultValue && (Array.isArray(defaultValue) ? defaultValue.length > 0 : true); - if (preselectedOption) { - const newValue = multiple ? [preselectedOption] : preselectedOption; - if (onChange) { - onChange(newValue, newValue?.addedFields); + if (!hasDefaultValue) { + // For multiple mode, check if value is empty array or null/undefined + // For single mode, check if value is null/undefined + const shouldPreselect = multiple + ? !value || (Array.isArray(value) && value.length === 0) + : !value; + + if (shouldPreselect) { + const preselectedOption = memoizedOptions.find( + (option) => option.value === preselectedValue + ); + + if (preselectedOption) { + const newValue = multiple ? [preselectedOption] : preselectedOption; + hasPreselectedRef.current = true; // Mark that we've preselected + if (onChange) { + onChange(newValue, newValue?.addedFields); + } + } } } } diff --git a/src/components/CippComponents/CippCalendarPermissionsDialog.jsx b/src/components/CippComponents/CippCalendarPermissionsDialog.jsx index d2d4c89d71fe..b8c63e2994d2 100644 --- a/src/components/CippComponents/CippCalendarPermissionsDialog.jsx +++ b/src/components/CippComponents/CippCalendarPermissionsDialog.jsx @@ -17,6 +17,15 @@ const CippCalendarPermissionsDialog = ({ formHook, combinedOptions, isUserGroupL } }, [isEditor, formHook]); + // default SendNotificationToUser to false on mount + useEffect(() => { + formHook.setValue("SendNotificationToUser", false); + }, [formHook]); + + // Only certain permission levels support sending a notification when calendar permissions are added + const notifyAllowed = ["AvailabilityOnly", "LimitedDetails", "Reviewer", "Editor"]; + const isNotifyAllowed = notifyAllowed.includes(permissionLevel?.value ?? permissionLevel); + return ( @@ -80,6 +89,29 @@ const CippCalendarPermissionsDialog = ({ formHook, combinedOptions, isUserGroupL + + + + + + + + ); }; diff --git a/src/components/CippComponents/CippExchangeActions.jsx b/src/components/CippComponents/CippExchangeActions.jsx index f0015c0fd3f6..1f7a5cf402d6 100644 --- a/src/components/CippComponents/CippExchangeActions.jsx +++ b/src/components/CippComponents/CippExchangeActions.jsx @@ -198,6 +198,51 @@ export const CippExchangeActions = () => { multiPost: false, condition: (row) => row.ArchiveGuid === "00000000-0000-0000-0000-000000000000", }, + { + label: "Set Retention Policy", + type: "POST", + url: "/api/ExecSetMailboxRetentionPolicies", + icon: , + confirmText: "Set the specified retention policy for selected mailboxes?", + multiPost: false, + fields: [ + { + type: "autoComplete", + name: "policyName", + label: "Retention Policy", + multiple: false, + creatable: false, + validators: { required: "Please select a retention policy" }, + api: { + url: "/api/ExecManageRetentionPolicies", + labelField: "Name", + valueField: "Name", + queryKey: `RetentionPolicies-${tenant}`, + data: { + tenantFilter: tenant, + }, + }, + }, + ], + customDataformatter: (rows, action, formData) => { + const mailboxArray = Array.isArray(rows) ? rows : [rows]; + + // Extract mailbox identities - using UPN as the identifier + const mailboxes = mailboxArray.map(mailbox => mailbox.UPN); + + // Handle autocomplete selection - could be string or object + const policyName = typeof formData.policyName === 'object' + ? formData.policyName.value + : formData.policyName; + + return { + PolicyName: policyName, + Mailboxes: mailboxes, + tenantFilter: tenant + }; + }, + color: "primary", + }, { label: "Enable Auto-Expanding Archive", type: "POST", diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx index a92ccda882d0..6b9635fe5bb4 100644 --- a/src/components/CippComponents/CippPolicyDeployDrawer.jsx +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -110,6 +110,7 @@ export const CippPolicyDeployDrawer = ({ required={true} disableClearable={false} allTenants={true} + preselectedEnabled={true} type="multiple" /> { }, confirmText: "Are you sure you want to clear the Immutable ID for [userPrincipalName]?", multiPost: false, - condition: (row) => !row.onPremisesSyncEnabled && row?.onPremisesImmutableId && canWriteUser, + condition: (row) => !row?.onPremisesSyncEnabled && row?.onPremisesImmutableId && canWriteUser, }, { label: "Revoke all user sessions", @@ -465,17 +465,19 @@ export const CippUserActions = () => { customFunction: (users, action, formData) => { // Handle both single user and multiple users const userData = Array.isArray(users) ? users : [users]; - + // Store users in session storage to avoid URL length limits - sessionStorage.setItem('patchWizardUsers', JSON.stringify(userData)); - + sessionStorage.setItem("patchWizardUsers", JSON.stringify(userData)); + // Use Next.js router for internal navigation - import('next/router').then(({ default: router }) => { - router.push('/identity/administration/users/patch-wizard'); - }).catch(() => { - // Fallback to window.location if router is not available - window.location.href = '/identity/administration/users/patch-wizard'; - }); + import("next/router") + .then(({ default: router }) => { + router.push("/identity/administration/users/patch-wizard"); + }) + .catch(() => { + // Fallback to window.location if router is not available + window.location.href = "/identity/administration/users/patch-wizard"; + }); }, condition: () => canWriteUser, }, diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 8409e4dd31a6..3d2cc8a89f64 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -200,6 +200,20 @@ export const CippDataTable = (props) => { }; const table = useMaterialReactTable({ + muiTableBodyCellProps: { + onCopy: (e) => { + const sel = window.getSelection()?.toString() ?? ""; + if (sel) { + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent?.stopImmediatePropagation?.(); + e.clipboardData.setData("text/plain", sel); + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(sel).catch(() => {}); + } + } + }, + }, mrtTheme: (theme) => ({ baseBackgroundColor: theme.palette.background.paper, }), @@ -215,66 +229,66 @@ export const CippDataTable = (props) => { muiTableHeadCellProps: { sx: { // Target the filter row cells - '& .MuiTableCell-root': { - padding: '8px 16px', + "& .MuiTableCell-root": { + padding: "8px 16px", }, // Target the Autocomplete component in filter cells - '& .MuiAutocomplete-root': { - width: '100%', + "& .MuiAutocomplete-root": { + width: "100%", }, // Force the tags container to be single line with ellipsis - '& .MuiAutocomplete-root .MuiInputBase-root': { - height: '40px !important', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - display: 'flex', - flexWrap: 'nowrap', + "& .MuiAutocomplete-root .MuiInputBase-root": { + height: "40px !important", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + display: "flex", + flexWrap: "nowrap", }, // Target the tags container specifically - '& .MuiAutocomplete-root .MuiInputBase-root .MuiInputBase-input': { - height: '24px', - minHeight: '24px', - maxHeight: '24px', + "& .MuiAutocomplete-root .MuiInputBase-root .MuiInputBase-input": { + height: "24px", + minHeight: "24px", + maxHeight: "24px", }, // Target regular input fields (not in Autocomplete) - '& .MuiInputBase-root': { - height: '40px !important', + "& .MuiInputBase-root": { + height: "40px !important", }, // Ensure all input fields have consistent styling - '& .MuiInputBase-input': { - height: '24px', - minHeight: '24px', - maxHeight: '24px', + "& .MuiInputBase-input": { + height: "24px", + minHeight: "24px", + maxHeight: "24px", }, // Target the specific chip class mentioned - '& .MuiChip-label.MuiChip-labelMedium': { - maxWidth: '80px', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - padding: '0 4px', + "& .MuiChip-label.MuiChip-labelMedium": { + maxWidth: "80px", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + padding: "0 4px", }, // Make chips smaller overall and add title attribute for tooltip - '& .MuiChip-root': { - height: '24px', - maxHeight: '24px', + "& .MuiChip-root": { + height: "24px", + maxHeight: "24px", // This adds a tooltip effect using the browser's native tooltip - '&::before': { - content: 'attr(data-label)', - display: 'none', + "&::before": { + content: "attr(data-label)", + display: "none", }, - '&:hover::before': { - display: 'block', - position: 'absolute', - top: '-25px', - left: '0', - backgroundColor: 'rgba(0, 0, 0, 0.8)', - color: 'white', - padding: '4px 8px', - borderRadius: '4px', - fontSize: '12px', - whiteSpace: 'nowrap', + "&:hover::before": { + display: "block", + position: "absolute", + top: "-25px", + left: "0", + backgroundColor: "rgba(0, 0, 0, 0.8)", + color: "white", + padding: "4px 8px", + borderRadius: "4px", + fontSize: "12px", + whiteSpace: "nowrap", zIndex: 9999, }, }, @@ -570,7 +584,7 @@ export const CippDataTable = (props) => { ) : ( // Render the table inside a Card - ( + {cardButton || !hideTitle ? ( <> @@ -602,7 +616,7 @@ export const CippDataTable = (props) => { )} - ) + )} { + const pageTitle = "Retention Policy Management"; + const tenant = useSettings().currentTenant; + + const actions = useMemo(() => [ + { + label: "Edit Policy", + link: "/email/administration/exchange-retention/policies/policy?name=[Name]", + multiPost: false, + postEntireRow: true, + icon: , + color: "warning", + }, + { + label: "Delete Policy", + type: "POST", + url: "/api/ExecManageRetentionPolicies", + confirmText: "Are you sure you want to delete retention policy [Name]? This action cannot be undone.", + color: "danger", + icon: , + customDataformatter: (rows) => { + const policies = Array.isArray(rows) ? rows : [rows]; + return { + DeletePolicies: policies.map(policy => policy.Name), + tenantFilter: tenant, + }; + }, + }, + ], [tenant]); + + const simpleColumns = useMemo(() => [ + "Name", + "IsDefault", + "IsDefaultArbitrationMailbox", + "RetentionPolicyTagLinks" + ], []); + + const cardButton = useMemo(() => ( + + ), []); + + return ( + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; \ No newline at end of file diff --git a/src/pages/email/administration/exchange-retention/policies/policy.jsx b/src/pages/email/administration/exchange-retention/policies/policy.jsx new file mode 100644 index 000000000000..7abef58d6a57 --- /dev/null +++ b/src/pages/email/administration/exchange-retention/policies/policy.jsx @@ -0,0 +1,151 @@ +import { useForm } from "react-hook-form"; +import { useEffect, useMemo } from "react"; +import { useRouter } from "next/router"; +import { Layout as DashboardLayout } from "/src/layouts/index"; +import CippFormPage from "/src/components/CippFormPages/CippFormPage"; +import CippFormSkeleton from "/src/components/CippFormPages/CippFormSkeleton"; +import { useSettings } from "/src/hooks/use-settings"; +import { Grid } from "@mui/system"; +import { Divider } from "@mui/material"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { ApiGetCall } from "/src/api/ApiCall"; + +const RetentionPolicy = () => { + const userSettingsDefaults = useSettings(); + const router = useRouter(); + const { name } = router.query; + const isEdit = !!name; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + Name: "", + RetentionPolicyTagLinks: [], + }, + }); + + // Get existing policy data if editing + const existingPolicyRequest = ApiGetCall({ + url: `/api/ExecManageRetentionPolicies?tenantFilter=${userSettingsDefaults.currentTenant}${isEdit ? `&name=${encodeURIComponent(name)}` : ''}`, + queryKey: `RetentionPolicy-${name}-${userSettingsDefaults.currentTenant}`, + waiting: isEdit, + }); + + // Get available retention tags + const retentionTagsRequest = ApiGetCall({ + url: `/api/ExecManageRetentionTags?tenantFilter=${userSettingsDefaults.currentTenant}`, + queryKey: `RetentionTags-ForManagement${userSettingsDefaults.currentTenant}`, + }); + + const availableTags = useMemo(() => { + if (!retentionTagsRequest.isSuccess || !retentionTagsRequest.data) { + return []; + } + + return retentionTagsRequest.data.map(tag => ({ + label: `${tag.Name} (${tag.Type})`, + value: tag.Name, + })); + }, [retentionTagsRequest.isSuccess, retentionTagsRequest.data]); + + // Pre-fill form when editing + useEffect(() => { + if (isEdit && existingPolicyRequest.isSuccess && existingPolicyRequest.data && availableTags.length > 0) { + const policy = existingPolicyRequest.data; + + // Map tag names to tag objects for the form + const selectedTags = policy.RetentionPolicyTagLinks.map(tagName => + availableTags.find(tag => tag.value === tagName) + ).filter(Boolean); + + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + Name: policy.Name, + RetentionPolicyTagLinks: selectedTags, + }); + } + }, [ + isEdit, + existingPolicyRequest.isSuccess, + existingPolicyRequest.data, + availableTags, + userSettingsDefaults.currentTenant, + formControl + ]); + + return ( + { + // Extract tag names from the selected tag objects + const tagNames = values.RetentionPolicyTagLinks?.map(tag => + typeof tag === 'string' ? tag : tag.value + ) || []; + + if (isEdit) { + return { + ModifyPolicies: [{ + Identity: name, + Name: values.Name, + RetentionPolicyTagLinks: tagNames, + }], + tenantFilter: values.tenantFilter, + }; + } else { + return { + CreatePolicies: [{ + Name: values.Name, + RetentionPolicyTagLinks: tagNames, + }], + tenantFilter: values.tenantFilter, + }; + } + }} + > + {((existingPolicyRequest.isLoading && isEdit) || retentionTagsRequest.isLoading) && ( + + )} + {(!isEdit || !existingPolicyRequest.isLoading) && !retentionTagsRequest.isLoading && ( + + {/* Policy Name */} + + + + + + + {/* Retention Tags */} + + + + + )} + + ); +}; + +RetentionPolicy.getLayout = (page) => {page}; + +export default RetentionPolicy; \ No newline at end of file diff --git a/src/pages/email/administration/exchange-retention/tabOptions.json b/src/pages/email/administration/exchange-retention/tabOptions.json new file mode 100644 index 000000000000..e6e203b5c611 --- /dev/null +++ b/src/pages/email/administration/exchange-retention/tabOptions.json @@ -0,0 +1,10 @@ +[ + { + "label": "Policies", + "path": "/email/administration/exchange-retention/policies" + }, + { + "label": "Tags", + "path": "/email/administration/exchange-retention/tags" + } +] diff --git a/src/pages/email/administration/exchange-retention/tags/index.js b/src/pages/email/administration/exchange-retention/tags/index.js new file mode 100644 index 000000000000..e8299b401eca --- /dev/null +++ b/src/pages/email/administration/exchange-retention/tags/index.js @@ -0,0 +1,80 @@ +import { useMemo } from "react"; +import { Layout as DashboardLayout } from "/src/layouts/index"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage"; +import { Sell, Edit } from "@mui/icons-material"; +import { Button } from "@mui/material"; +import Link from "next/link"; +import TrashIcon from "@heroicons/react/24/outline/TrashIcon"; +import { HeaderedTabbedLayout } from "/src/layouts/HeaderedTabbedLayout"; +import tabOptions from "../tabOptions"; +import { useSettings } from "/src/hooks/use-settings"; + +const Page = () => { + const pageTitle = "Retention Tag Management"; + const tenant = useSettings().currentTenant; + + const actions = useMemo(() => [ + { + label: "Edit Tag", + link: "/email/administration/exchange-retention/tags/tag?name=[Name]", + multiPost: false, + postEntireRow: true, + icon: , + color: "warning", + }, + { + label: "Delete Tag", + type: "POST", + url: "/api/ExecManageRetentionTags", + confirmText: "Are you sure you want to delete retention tag [Name]? This action cannot be undone and may affect retention policies that use this tag.", + color: "danger", + icon: , + customDataformatter: (rows) => { + const tags = Array.isArray(rows) ? rows : [rows]; + return { + DeleteTags: tags.map(tag => tag.Name), + tenantFilter: tenant, + }; + }, + }, + ], [tenant]); + + const simpleColumns = useMemo(() => [ + "Name", + "Type", + "RetentionAction", + "AgeLimitForRetention", + "RetentionEnabled", + "Comment" + ], []); + + const cardButton = useMemo(() => ( + + ), []); + + return ( + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; \ No newline at end of file diff --git a/src/pages/email/administration/exchange-retention/tags/tag.jsx b/src/pages/email/administration/exchange-retention/tags/tag.jsx new file mode 100644 index 000000000000..81fb60b87505 --- /dev/null +++ b/src/pages/email/administration/exchange-retention/tags/tag.jsx @@ -0,0 +1,276 @@ +import { useForm } from "react-hook-form"; +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import CippFormPage from "/src/components/CippFormPages/CippFormPage"; +import CippFormSkeleton from "/src/components/CippFormPages/CippFormSkeleton"; +import { useSettings } from "/src/hooks/use-settings"; +import { Grid } from "@mui/system"; +import { Divider } from "@mui/material"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { ApiGetCall } from "/src/api/ApiCall"; + +const RetentionTag = () => { + const userSettingsDefaults = useSettings(); + const router = useRouter(); + const { name } = router.query; + const isEdit = !!name; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + Name: "", + Type: "", + Comment: "", + RetentionAction: "", + AgeLimitForRetention: "", + RetentionEnabled: true, + LocalizedComment: "", + LocalizedRetentionPolicyTagName: "", + }, + }); + + // Get existing tag data if editing + const existingTagRequest = ApiGetCall({ + url: `/api/ExecManageRetentionTags?tenantFilter=${userSettingsDefaults.currentTenant}${isEdit ? `&name=${encodeURIComponent(name)}` : ''}`, + queryKey: `RetentionTag-${name}-${userSettingsDefaults.currentTenant}`, + waiting: isEdit, + }); + + const tagTypes = [ + { label: 'All', value: 'All' }, + { label: 'Inbox', value: 'Inbox' }, + { label: 'Sent Items', value: 'SentItems' }, + { label: 'Deleted Items', value: 'DeletedItems' }, + { label: 'Drafts', value: 'Drafts' }, + { label: 'Outbox', value: 'Outbox' }, + { label: 'Junk Email', value: 'JunkEmail' }, + { label: 'Journal', value: 'Journal' }, + { label: 'Sync Issues', value: 'SyncIssues' }, + { label: 'Conversation History', value: 'ConversationHistory' }, + { label: 'Personal', value: 'Personal' }, + { label: 'Recoverable Items', value: 'RecoverableItems' }, + { label: 'Non IPM Root', value: 'NonIpmRoot' }, + { label: 'Legacy Archive Journals', value: 'LegacyArchiveJournals' }, + { label: 'Clutter', value: 'Clutter' }, + { label: 'Calendar', value: 'Calendar' }, + { label: 'Notes', value: 'Notes' }, + { label: 'Tasks', value: 'Tasks' }, + { label: 'Contacts', value: 'Contacts' }, + { label: 'RSS Subscriptions', value: 'RssSubscriptions' }, + { label: 'Managed Custom Folder', value: 'ManagedCustomFolder' } + ]; + + const retentionActions = [ + { label: 'Delete and Allow Recovery', value: 'DeleteAndAllowRecovery' }, + { label: 'Permanently Delete', value: 'PermanentlyDelete' }, + { label: 'Move to Archive', value: 'MoveToArchive' }, + { label: 'Mark as Past Retention Limit', value: 'MarkAsPastRetentionLimit' } + ]; + + // Parse AgeLimitForRetention from TimeSpan format "90.00:00:00" to just days "90" + const parseAgeLimitDays = (ageLimit) => { + if (!ageLimit) return ""; + const match = ageLimit.toString().match(/^(\d+)\./); + return match ? match[1] : ""; + }; + + // Pre-fill form when editing + useEffect(() => { + if (isEdit && existingTagRequest.isSuccess && existingTagRequest.data) { + const tag = existingTagRequest.data; + + // Find the matching options for dropdowns + const typeOption = tagTypes.find(option => option.value === tag.Type) || null; + const actionOption = retentionActions.find(option => option.value === tag.RetentionAction) || null; + + // Handle localized fields (arrays in API, strings in form) + const localizedComment = Array.isArray(tag.LocalizedComment) + ? tag.LocalizedComment[0] || "" + : tag.LocalizedComment || ""; + const localizedTagName = Array.isArray(tag.LocalizedRetentionPolicyTagName) + ? tag.LocalizedRetentionPolicyTagName[0] || "" + : tag.LocalizedRetentionPolicyTagName || ""; + + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + Name: tag.Name || "", + Type: typeOption, + Comment: tag.Comment || "", + RetentionAction: actionOption, + AgeLimitForRetention: parseAgeLimitDays(tag.AgeLimitForRetention), + RetentionEnabled: tag.RetentionEnabled !== false, + LocalizedComment: localizedComment, + LocalizedRetentionPolicyTagName: localizedTagName, + }); + } + }, [isEdit, existingTagRequest.isSuccess, existingTagRequest.data, userSettingsDefaults.currentTenant, formControl]); + + return ( + { + const tagData = { + Name: values.Name, + Comment: values.Comment, + RetentionEnabled: values.RetentionEnabled, + }; + + // Extract .value from select objects and only include non-empty optional fields + if (values.RetentionAction) { + tagData.RetentionAction = typeof values.RetentionAction === 'string' + ? values.RetentionAction + : values.RetentionAction.value; + } + if (values.AgeLimitForRetention) { + tagData.AgeLimitForRetention = parseInt(values.AgeLimitForRetention); + } + if (values.LocalizedComment) { + tagData.LocalizedComment = values.LocalizedComment; + } + if (values.LocalizedRetentionPolicyTagName) { + tagData.LocalizedRetentionPolicyTagName = values.LocalizedRetentionPolicyTagName; + } + + if (isEdit) { + return { + ModifyTags: [{ + Identity: name, + ...tagData, + }], + tenantFilter: values.tenantFilter, + }; + } else { + return { + CreateTags: [{ + Type: typeof values.Type === 'string' ? values.Type : values.Type.value, + ...tagData, + }], + tenantFilter: values.tenantFilter, + }; + } + }} + > + {existingTagRequest.isLoading && isEdit && } + {(!isEdit || !existingTagRequest.isLoading) && ( + + {/* Tag Name */} + + + + + {/* Tag Type */} + + + + + + + {/* Retention Action */} + + + + + {/* Age Limit */} + + + + + {/* Retention Enabled */} + + + + + + + {/* Comment */} + + + + + {/* Localized Fields */} + + + + + + + + + + + + )} + + ); +}; + +RetentionTag.getLayout = (page) => {page}; + +export default RetentionTag; \ No newline at end of file diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx index 22f77d21e093..f4ffafe2d2ed 100644 --- a/src/pages/identity/administration/users/user/exchange.jsx +++ b/src/pages/identity/administration/users/user/exchange.jsx @@ -235,6 +235,9 @@ const Page = () => { permission.CanViewPrivateItems = true; } + // Always include SendNotificationToUser explicitly (default false) + permission.SendNotificationToUser = Boolean(data.SendNotificationToUser); + return { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, diff --git a/src/pages/tenant/standards/list-standards/classic-standards/index.js b/src/pages/tenant/standards/list-standards/classic-standards/index.js index b650b4325433..c160d449526b 100644 --- a/src/pages/tenant/standards/list-standards/classic-standards/index.js +++ b/src/pages/tenant/standards/list-standards/classic-standards/index.js @@ -3,7 +3,7 @@ import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx" import { Layout as DashboardLayout } from "/src/layouts/index.js"; // had to add an extra path here because I added an extra folder structure. We should switch to absolute pathing so we dont have to deal with relative. import { TabbedLayout } from "/src/layouts/TabbedLayout"; import Link from "next/link"; -import { CopyAll, Delete, PlayArrow, AddBox, Edit, GitHub } from "@mui/icons-material"; +import { CopyAll, Delete, PlayArrow, AddBox, Edit, GitHub, ContentCopy } from "@mui/icons-material"; import { ApiGetCall, ApiPostCall } from "../../../../../api/ApiCall"; import { Grid } from "@mui/system"; import { CippApiResults } from "../../../../../components/CippComponents/CippApiResults"; @@ -37,11 +37,23 @@ const Page = () => { }, { label: "Clone & Edit Template", - link: "/tenant/standards/template?id=[GUID]&clone=true", + link: "/tenant/standards/template?id=[GUID]&clone=true&type=[type]", icon: , color: "success", target: "_self", }, + { + label: "Create Drift Clone", + type: "POST", + url: "/api/ExecDriftClone", + icon: , + color: "warning", + data: { + id: "GUID", + }, + confirmText: "Are you sure you want to create a drift clone of [templateName]? This will create a new drift template based on this template.", + multiPost: false, + }, { label: "Run Template Now (Currently Selected Tenant only)", type: "GET",