From 69216bafa1bd2bd21f9becb7ccd677159bfd63fc Mon Sep 17 00:00:00 2001 From: Zac Richards <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 10 Aug 2025 01:13:30 +0800 Subject: [PATCH 01/19] Feat: Exchange Retention Policy & Tag Management --- .../CippCards/CippExchangeInfoCard.jsx | 8 + .../CippComponents/CippExchangeActions.jsx | 45 +++ src/layouts/HeaderedTabbedLayout.jsx | 2 +- src/layouts/config.js | 6 + .../exchange-retention/policies/add.jsx | 151 ++++++++++ .../exchange-retention/policies/edit.jsx | 1 + .../exchange-retention/policies/index.js | 78 +++++ .../exchange-retention/tabOptions.json | 10 + .../exchange-retention/tags/add.jsx | 276 ++++++++++++++++++ .../exchange-retention/tags/edit.jsx | 1 + .../exchange-retention/tags/index.js | 80 +++++ 11 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 src/pages/email/administration/exchange-retention/policies/add.jsx create mode 100644 src/pages/email/administration/exchange-retention/policies/edit.jsx create mode 100644 src/pages/email/administration/exchange-retention/policies/index.js create mode 100644 src/pages/email/administration/exchange-retention/tabOptions.json create mode 100644 src/pages/email/administration/exchange-retention/tags/add.jsx create mode 100644 src/pages/email/administration/exchange-retention/tags/edit.jsx create mode 100644 src/pages/email/administration/exchange-retention/tags/index.js 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/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/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index 50b1b2e1e034..2b9965280666 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -60,7 +60,7 @@ export const HeaderedTabbedLayout = (props) => { > - +
+ ), []); + + return ( + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; \ 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/add.jsx b/src/pages/email/administration/exchange-retention/tags/add.jsx new file mode 100644 index 000000000000..97fb1218aafa --- /dev/null +++ b/src/pages/email/administration/exchange-retention/tags/add.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 AddRetentionTag = () => { + 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 */} + + + + + + + + + + + + )} + + ); +}; + +AddRetentionTag.getLayout = (page) => {page}; + +export default AddRetentionTag; diff --git a/src/pages/email/administration/exchange-retention/tags/edit.jsx b/src/pages/email/administration/exchange-retention/tags/edit.jsx new file mode 100644 index 000000000000..7df96e2df900 --- /dev/null +++ b/src/pages/email/administration/exchange-retention/tags/edit.jsx @@ -0,0 +1 @@ +export { default } from './add'; \ No newline at end of file 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..105a31c56e77 --- /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/edit?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 From 9f3819eb9fefdd5a12168bcf5cbbbc1fd1c1d0b6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:37:23 +0800 Subject: [PATCH 02/19] Update HeaderedTabbedLayout.jsx Signed-off-by: Zacgoose <107489668+Zacgoose@users.noreply.github.com> --- src/layouts/HeaderedTabbedLayout.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index 2b9965280666..50b1b2e1e034 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -60,7 +60,7 @@ export const HeaderedTabbedLayout = (props) => { > - +