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(() => (
+ }
+ >
+ Add Retention Policy
+
+ ), []);
+
+ 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(() => (
+ }
+ >
+ Add Retention Tag
+
+ ), []);
+
+ 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",