diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1bcdb9744954..91a6a5c671f2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -10,6 +10,12 @@ "type": "shell", "command": "azurite --location ../", "isBackground": true, + "options": { + "env": { + "LC_ALL": "en-US.UTF-8", + "LANG": "en-US" + } + }, "problemMatcher": { "pattern": [ { diff --git a/cspell.json b/cspell.json index fe7d6143946f..b51f8a518d31 100644 --- a/cspell.json +++ b/cspell.json @@ -30,6 +30,7 @@ "Reshare", "Rewst", "Sherweb", + "superadmin", "Syncro", "TERRL", "unconfigured", diff --git a/package.json b/package.json index 2ade75d4e6c0..1a5926d62bfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "8.4.2", + "version": "8.5.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/public/permissionsList.json b/public/permissionsList.json index c91c29126036..8ec55a707b7e 100644 --- a/public/permissionsList.json +++ b/public/permissionsList.json @@ -7969,5 +7969,25 @@ "userConsentDescription": "Allows the app to manage workforce integrations, to synchronize data from Microsoft Teams Shifts, on your behalf.", "userConsentDisplayName": "Read and write workforce integrations", "value": "WorkforceIntegration.ReadWrite.All" + }, + { + "description": "Read and Modify Tenant-Acquired Telephone Number Details", + "displayName": "Read and Modify Tenant-Acquired Telephone Number Details", + "id": "424b07a8-1209-4d17-9fe4-9018a93a1024", + "isEnabled": true, + "Origin": "Delegated", + "userConsentDescription": "Allows the app to read and modify your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "userConsentDisplayName": "Allows the app to read and modify your tenant's acquired telephone number details on behalf of the signed-in admin user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "value": "TeamsTelephoneNumber.ReadWrite.All" + }, + { + "description": "Read and Modify Tenant-Acquired Telephone Number Details", + "displayName": "Read and Modify Tenant-Acquired Telephone Number Details", + "id": "0a42382f-155c-4eb1-9bdc-21548ccaa387", + "isEnabled": true, + "Origin": "Application", + "userConsentDescription": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "userConsentDisplayName": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", + "value": "TeamsTelephoneNumber.ReadWrite.All" } ] diff --git a/public/version.json b/public/version.json index 6a8d059b3176..5cf63fb43067 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "8.4.2" + "version": "8.5.0" } diff --git a/src/api/ApiCall.jsx b/src/api/ApiCall.jsx index e39aeb8718ee..c069208c2dc4 100644 --- a/src/api/ApiCall.jsx +++ b/src/api/ApiCall.jsx @@ -76,7 +76,25 @@ export function ApiGetCall(props) { if (relatedQueryKeys) { const clearKeys = Array.isArray(relatedQueryKeys) ? relatedQueryKeys : [relatedQueryKeys]; setTimeout(() => { - clearKeys.forEach((key) => { + // Separate wildcard patterns from exact keys + const wildcardPatterns = clearKeys + .filter((key) => key.endsWith("*")) + .map((key) => key.slice(0, -1)); + const exactKeys = clearKeys.filter((key) => !key.endsWith("*")); + + // Use single predicate call for all wildcard patterns + if (wildcardPatterns.length > 0) { + queryClient.invalidateQueries({ + predicate: (query) => { + if (!query.queryKey || !query.queryKey[0]) return false; + const queryKeyStr = String(query.queryKey[0]); + return wildcardPatterns.some((pattern) => queryKeyStr.startsWith(pattern)); + }, + }); + } + + // Handle exact keys + exactKeys.forEach((key) => { queryClient.invalidateQueries({ queryKey: [key] }); }); }, 1000); @@ -96,7 +114,25 @@ export function ApiGetCall(props) { if (relatedQueryKeys) { const clearKeys = Array.isArray(relatedQueryKeys) ? relatedQueryKeys : [relatedQueryKeys]; setTimeout(() => { - clearKeys.forEach((key) => { + // Separate wildcard patterns from exact keys + const wildcardPatterns = clearKeys + .filter((key) => key.endsWith("*")) + .map((key) => key.slice(0, -1)); + const exactKeys = clearKeys.filter((key) => !key.endsWith("*")); + + // Use single predicate call for all wildcard patterns + if (wildcardPatterns.length > 0) { + queryClient.invalidateQueries({ + predicate: (query) => { + if (!query.queryKey || !query.queryKey[0]) return false; + const queryKeyStr = String(query.queryKey[0]); + return wildcardPatterns.some((pattern) => queryKeyStr.startsWith(pattern)); + }, + }); + } + + // Handle exact keys + exactKeys.forEach((key) => { queryClient.invalidateQueries({ queryKey: [key] }); }); }, 1000); @@ -117,6 +153,7 @@ export function ApiGetCall(props) { export function ApiPostCall({ relatedQueryKeys, onResult }) { const queryClient = useQueryClient(); + const mutation = useMutation({ mutationFn: async (props) => { const { url, data, bulkRequest } = props; @@ -144,9 +181,43 @@ export function ApiPostCall({ relatedQueryKeys, onResult }) { const clearKeys = Array.isArray(relatedQueryKeys) ? relatedQueryKeys : [relatedQueryKeys]; setTimeout(() => { if (relatedQueryKeys === "*") { + console.log("Invalidating all queries"); queryClient.invalidateQueries(); } else { - clearKeys.forEach((key) => { + // Separate wildcard patterns from exact keys + const wildcardPatterns = clearKeys + .filter((key) => key.endsWith("*")) + .map((key) => key.slice(0, -1)); + const exactKeys = clearKeys.filter((key) => !key.endsWith("*")); + + // Use single predicate call for all wildcard patterns + if (wildcardPatterns.length > 0) { + queryClient.invalidateQueries({ + predicate: (query) => { + if (!query.queryKey || !query.queryKey[0]) return false; + const queryKeyStr = String(query.queryKey[0]); + const matches = wildcardPatterns.some((pattern) => + queryKeyStr.startsWith(pattern) + ); + + // Debug logging for each query check + if (matches) { + console.log("Invalidating query:", { + queryKey: query.queryKey, + queryKeyStr, + matchedPattern: wildcardPatterns.find((pattern) => + queryKeyStr.startsWith(pattern) + ), + }); + } + + return matches; + }, + }); + } + + // Handle exact keys + exactKeys.forEach((key) => { queryClient.invalidateQueries({ queryKey: [key] }); }); } diff --git a/src/components/CippCards/CippChartCard.jsx b/src/components/CippCards/CippChartCard.jsx index b05652ef9ec8..577a3f2bbaf1 100644 --- a/src/components/CippCards/CippChartCard.jsx +++ b/src/components/CippCards/CippChartCard.jsx @@ -93,12 +93,15 @@ export const CippChartCard = ({ title, actions, onClick, + totalLabel = "Total", + customTotal, }) => { const [range, setRange] = useState("Last 7 days"); const [barSeries, setBarSeries] = useState([]); const chartOptions = useChartOptions(labels, chartType); chartSeries = chartSeries.filter((item) => item !== null); - const total = chartSeries.reduce((acc, value) => acc + value, 0); + const calculatedTotal = chartSeries.reduce((acc, value) => acc + value, 0); + const total = customTotal !== undefined ? customTotal : calculatedTotal; useEffect(() => { if (chartType === "bar") { setBarSeries( @@ -160,7 +163,7 @@ export const CippChartCard = ({ > {labels.length > 0 && ( <> - Total + {totalLabel} {isFetching ? "0" : total} > )} diff --git a/src/components/CippComponents/CippAddContactDrawer.jsx b/src/components/CippComponents/CippAddContactDrawer.jsx new file mode 100644 index 000000000000..f46b5b54f80e --- /dev/null +++ b/src/components/CippComponents/CippAddContactDrawer.jsx @@ -0,0 +1,321 @@ +import React, { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { PersonAdd } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddContactDrawer = ({ + buttonText = "Add Contact", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const tenantDomain = useSettings().currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + firstName: "", + lastName: "", + email: "", + hidefromGAL: false, + streetAddress: "", + postalCode: "", + city: "", + state: "", + country: "", + companyName: "", + mobilePhone: "", + businessPhone: "", + jobTitle: "", + website: "", + mailTip: "", + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const addContact = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`Contacts-${tenantDomain}`], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (addContact.isSuccess) { + formControl.reset({ + displayName: "", + firstName: "", + lastName: "", + email: "", + hidefromGAL: false, + streetAddress: "", + postalCode: "", + city: "", + state: "", + country: "", + companyName: "", + mobilePhone: "", + businessPhone: "", + jobTitle: "", + website: "", + mailTip: "", + }); + } + }, [addContact.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + const shippedValues = { + tenantID: tenantDomain, + DisplayName: formData.displayName, + hidefromGAL: formData.hidefromGAL, + email: formData.email, + FirstName: formData.firstName, + LastName: formData.lastName, + Title: formData.jobTitle, + StreetAddress: formData.streetAddress, + PostalCode: formData.postalCode, + City: formData.city, + State: formData.state, + CountryOrRegion: formData.country?.value || formData.country, + Company: formData.companyName, + mobilePhone: formData.mobilePhone, + phone: formData.businessPhone, + website: formData.website, + mailTip: formData.mailTip, + }; + + addContact.mutate({ + url: "/api/AddContact", + data: shippedValues, + relatedQueryKeys: [`Contacts-${tenantDomain}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + firstName: "", + lastName: "", + email: "", + hidefromGAL: false, + streetAddress: "", + postalCode: "", + city: "", + state: "", + country: "", + companyName: "", + mobilePhone: "", + businessPhone: "", + jobTitle: "", + website: "", + mailTip: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + {addContact.isLoading + ? "Creating..." + : addContact.isSuccess + ? "Create Another" + : "Create Contact"} + + + Close + + + } + > + + {/* Display Name */} + + + + + {/* First Name and Last Name */} + + + + + + + + + + {/* Email */} + + + + + {/* Hide from GAL */} + + + + + + + {/* Additional Contact Information */} + + + + + + + + {/* Phone Numbers */} + + + + + + + + {/* Address Information */} + + + + + + + + + + + + + + {/* Website and Mail Tip */} + + + + {/* Website and Mail Tip */} + + + + + + + + > + ); +}; diff --git a/src/components/CippComponents/CippAddEquipmentDrawer.jsx b/src/components/CippComponents/CippAddEquipmentDrawer.jsx new file mode 100644 index 000000000000..3d9a397765c6 --- /dev/null +++ b/src/components/CippComponents/CippAddEquipmentDrawer.jsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { AddBusiness } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddEquipmentDrawer = ({ + buttonText = "Add Equipment Mailbox", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const tenantDomain = useSettings().currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + username: "", + domain: null, + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const addEquipment = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`EquipmentMailbox-${tenantDomain}`], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (addEquipment.isSuccess) { + formControl.reset(); + } + }, [addEquipment.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + const shippedValues = { + tenantID: tenantDomain, + domain: formData.domain?.value, + displayName: formData.displayName.trim(), + username: formData.username.trim(), + userPrincipalName: formData.username.trim() + "@" + (formData.domain?.value || "").trim(), + }; + + addEquipment.mutate({ + url: "/api/AddEquipmentMailbox", + data: shippedValues, + relatedQueryKeys: [`EquipmentMailbox-${tenantDomain}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + username: "", + domain: null, + location: "", + department: "", + company: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + {addEquipment.isLoading + ? "Creating..." + : addEquipment.isSuccess + ? "Create Another" + : "Create Equipment Mailbox"} + + + Close + + + } + > + + {/* Display Name */} + + + + + + + {/* Username and Domain */} + + + + + + + + + + + > + ); +}; diff --git a/src/components/CippComponents/CippAddRoomDrawer.jsx b/src/components/CippComponents/CippAddRoomDrawer.jsx new file mode 100644 index 000000000000..8dc5060908aa --- /dev/null +++ b/src/components/CippComponents/CippAddRoomDrawer.jsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { AddHomeWork } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddRoomDrawer = ({ + buttonText = "Add Room Mailbox", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const tenantDomain = useSettings().currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + username: "", + domain: null, + resourceCapacity: "", + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const addRoom = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`RoomMailbox-${tenantDomain}`], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (addRoom.isSuccess) { + formControl.reset({ + displayName: "", + username: "", + domain: null, + resourceCapacity: "", + }); + } + }, [addRoom.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + const shippedValues = { + tenantID: tenantDomain, + domain: formData.domain?.value, + displayName: formData.displayName.trim(), + username: formData.username.trim(), + userPrincipalName: formData.username.trim() + "@" + (formData.domain?.value || "").trim(), + }; + + if (formData.resourceCapacity && formData.resourceCapacity.trim() !== "") { + shippedValues.resourceCapacity = formData.resourceCapacity.trim(); + } + + addRoom.mutate({ + url: "/api/AddRoomMailbox", + data: shippedValues, + relatedQueryKeys: [`RoomMailbox-${tenantDomain}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + username: "", + domain: null, + resourceCapacity: "", + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + {addRoom.isLoading + ? "Creating..." + : addRoom.isSuccess + ? "Create Another" + : "Create Room Mailbox"} + + + Close + + + } + > + + {/* Display Name */} + + + + + + + {/* Username and Domain */} + + + + + + + + + + {/* Resource Capacity (Optional) */} + + + + + + + + > + ); +}; diff --git a/src/components/CippComponents/CippAddRoomListDrawer.jsx b/src/components/CippComponents/CippAddRoomListDrawer.jsx new file mode 100644 index 000000000000..6ced8947993b --- /dev/null +++ b/src/components/CippComponents/CippAddRoomListDrawer.jsx @@ -0,0 +1,159 @@ +import React, { useState, useEffect } from "react"; +import { Button, InputAdornment, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { ListAlt } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormDomainSelector } from "./CippFormDomainSelector"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippAddRoomListDrawer = ({ + buttonText = "Add Room List", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + const tenantDomain = userSettingsDefaults.currentTenant; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + displayName: "", + username: "", + primDomain: null, + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const addRoomList = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`RoomLists-${tenantDomain}`], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (addRoomList.isSuccess) { + formControl.reset({ + displayName: "", + username: "", + primDomain: null, + }); + } + }, [addRoomList.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + const shippedValues = { + tenantFilter: tenantDomain, + displayName: formData.displayName?.trim(), + username: formData.username?.trim(), + primDomain: formData.primDomain, + }; + + addRoomList.mutate({ + url: "/api/AddRoomList", + data: shippedValues, + relatedQueryKeys: [`RoomLists-${tenantDomain}`], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + displayName: "", + username: "", + primDomain: null, + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + {addRoomList.isLoading + ? "Creating..." + : addRoomList.isSuccess + ? "Create Another" + : "Create Room List"} + + + Close + + + } + > + + + + + + + @, + }} + /> + + + + + + + + + + > + ); +}; diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index 6d441e7412ce..e45fe1a22365 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -133,11 +133,16 @@ export const CippApiDialog = (props) => { return; } - const commonData = { - tenantFilter, - ...formData, - ...addedFieldData, + // Helper function to get the correct tenant filter for a row + const getRowTenantFilter = (rowData) => { + // If we're in AllTenants mode and the row has a Tenant property, use that + if (tenantFilter === "AllTenants" && rowData?.Tenant) { + return rowData.Tenant; + } + // Otherwise use the current tenant filter + return tenantFilter; }; + const processedActionData = processActionData(action.data, row, action.replacementBehaviour); if (!processedActionData || Object.keys(processedActionData).length === 0) { @@ -146,6 +151,11 @@ export const CippApiDialog = (props) => { // MULTI ROW CASES if (Array.isArray(row)) { const arrayData = row.map((singleRow) => { + const commonData = { + tenantFilter: getRowTenantFilter(singleRow), + ...formData, + ...addedFieldData, + }; const itemData = { ...commonData }; Object.keys(processedActionData).forEach((key) => { const rowValue = singleRow[processedActionData[key]]; @@ -173,6 +183,14 @@ export const CippApiDialog = (props) => { return; } } + + // SINGLE ROW CASE + const commonData = { + tenantFilter: getRowTenantFilter(row), + ...formData, + ...addedFieldData, + }; + // ✅ FIXED: DIRECT MERGE INSTEAD OF CORRUPT TRANSFORMATION finalData = { ...commonData, diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx index 9fc0e50c4ee1..b8b095937725 100644 --- a/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -599,6 +599,14 @@ export const CippApplicationDeployDrawer = ({ formControl={formControl} /> + + + {/* Install Options */} @@ -751,6 +759,44 @@ export const CippApplicationDeployDrawer = ({ defaultValue={true} /> + + + + + + + + Provide a custom Office Configuration XML. When using custom XML, all other + Office configuration options above will be ignored. See{" "} + + Office Customization Tool + {" "} + to generate XML. + + + {/* Assign To Options */} diff --git a/src/components/CippComponents/CippAuditLogSearchDrawer.jsx b/src/components/CippComponents/CippAuditLogSearchDrawer.jsx index 2665715034ca..93628842b386 100644 --- a/src/components/CippComponents/CippAuditLogSearchDrawer.jsx +++ b/src/components/CippComponents/CippAuditLogSearchDrawer.jsx @@ -172,6 +172,7 @@ export const CippAuditLogSearchDrawer = ({ label: "Search Name", required: true, validators: { required: "Search name is required" }, + disableVariables: true, }, { type: "autoComplete", diff --git a/src/components/CippComponents/CippAutocomplete.jsx b/src/components/CippComponents/CippAutocomplete.jsx index 6ac86f53d4ba..9ed0e358d594 100644 --- a/src/components/CippComponents/CippAutocomplete.jsx +++ b/src/components/CippComponents/CippAutocomplete.jsx @@ -79,6 +79,7 @@ export const CippAutoComplete = (props) => { const [usedOptions, setUsedOptions] = useState(options); const [getRequestInfo, setGetRequestInfo] = useState({ url: "", waiting: false, queryKey: "" }); const hasPreselectedRef = useRef(false); + const autocompleteRef = useRef(null); // Ref for focusing input after selection const filter = createFilterOptions({ stringify: (option) => JSON.stringify(option), }); @@ -208,9 +209,9 @@ export const CippAutoComplete = (props) => { return finalOptions; }, [api, usedOptions, options, removeOptions, sortOptions]); - // Dedicated effect for handling preselected value - only runs once + // Dedicated effect for handling preselected value or auto-select first item - only runs once useEffect(() => { - if (preselectedValue && memoizedOptions.length > 0 && !hasPreselectedRef.current) { + if (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); @@ -223,9 +224,16 @@ export const CippAutoComplete = (props) => { : !value; if (shouldPreselect) { - const preselectedOption = memoizedOptions.find( - (option) => option.value === preselectedValue - ); + let preselectedOption; + + // Handle explicit preselected value + if (preselectedValue) { + preselectedOption = memoizedOptions.find((option) => option.value === preselectedValue); + } + // Handle auto-select first item from API + else if (api?.autoSelectFirstItem && memoizedOptions.length > 0) { + preselectedOption = memoizedOptions[0]; + } if (preselectedOption) { const newValue = multiple ? [preselectedOption] : preselectedOption; @@ -237,7 +245,15 @@ export const CippAutoComplete = (props) => { } } } - }, [preselectedValue, defaultValue, value, memoizedOptions, multiple, onChange]); + }, [ + preselectedValue, + defaultValue, + value, + memoizedOptions, + multiple, + onChange, + api?.autoSelectFirstItem, + ]); // Create a stable key that only changes when necessary inputs change const stableKey = useMemo(() => { @@ -261,6 +277,7 @@ export const CippAutoComplete = (props) => { return ( { if (onChange) { onChange(newValue, newValue?.addedFields); } + + // In multiple mode, refocus the input after selection to allow continuous adding + if (multiple && newValue && autocompleteRef.current) { + // Use setTimeout to ensure the selection is processed first + setTimeout(() => { + const input = autocompleteRef.current?.querySelector("input"); + if (input) { + input.focus(); + } + }, 0); + } }} options={memoizedOptions} getOptionLabel={useCallback( @@ -365,6 +393,20 @@ export const CippAutoComplete = (props) => { }, [api] )} + onKeyDown={(event) => { + // Handle Tab key to select highlighted option + if (event.key === "Tab" && !event.shiftKey) { + // Check if there's a highlighted option + const listbox = document.querySelector('[role="listbox"]'); + const highlightedOption = listbox?.querySelector('[data-focus="true"], .Mui-focused'); + + if (highlightedOption && listbox?.style.display !== "none") { + event.preventDefault(); + // Trigger a click on the highlighted option + highlightedOption.click(); + } + } + }} sx={sx} renderInput={(params) => ( diff --git a/src/components/CippComponents/CippAutopilotProfileDrawer.jsx b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx index 90a7f852dc3f..9ac21d1fd858 100644 --- a/src/components/CippComponents/CippAutopilotProfileDrawer.jsx +++ b/src/components/CippComponents/CippAutopilotProfileDrawer.jsx @@ -188,6 +188,8 @@ export const CippAutopilotProfileDrawer = ({ label="Hide Change Account Options" name="HideChangeAccount" formControl={formControl} + disabled={true} + helperText="This setting requires Hybrid Azure AD Join which is not supported in CIPP" /> { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onBlur", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + }, + }); + + const createBackup = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`BackupTasks-${userSettingsDefaults.currentTenant}`], + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + useEffect(() => { + if (createBackup.isSuccess) { + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + }); + // Call onSuccess callback if provided + if (onSuccess) { + onSuccess(); + } + } + }, [createBackup.isSuccess, onSuccess]); + + const handleSubmit = () => { + formControl.trigger(); + if (!isValid) { + return; + } + const values = formControl.getValues(); + const startDate = new Date(); + startDate.setHours(0, 0, 0, 0); + const unixTime = Math.floor(startDate.getTime() / 1000) - 45; + const tenantFilter = values.tenantFilter || userSettingsDefaults.currentTenant; + + const shippedValues = { + TenantFilter: tenantFilter, + Name: `CIPP Backup - ${tenantFilter}`, + Command: { value: `New-CIPPBackup` }, + Parameters: { + backupType: "Scheduled", + ScheduledBackupValues: { ...omit(values, ["tenantFilter"]) }, + }, + ScheduledTime: unixTime, + Recurrence: { value: "1d" }, + }; + + createBackup.mutate({ + url: "/api/AddScheduledItem?hidden=true&DisallowDuplicateName=true", + data: shippedValues, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: userSettingsDefaults.currentTenant, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + {createBackup.isPending + ? "Creating Schedule..." + : createBackup.isSuccess + ? "Create Another Schedule" + : "Create Schedule"} + + + Close + + + } + > + + + Backup Schedule Information + Create a scheduled backup task that will automatically backup your tenant configuration. + Backups are stored securely and can be restored using the restore functionality. + + + + + Tenant Selection + + + + + Identity + + + + + + + + + + Conditional Access + + + + + + + + Intune + + + + + + + + + + + + + + Email Security + + + + + + + + + + CIPP + + + + + + + + + + + + + + + > + ); +}; diff --git a/src/components/CippComponents/CippCADeployDrawer.jsx b/src/components/CippComponents/CippCADeployDrawer.jsx index 403c4657fa03..77e604672f9e 100644 --- a/src/components/CippComponents/CippCADeployDrawer.jsx +++ b/src/components/CippComponents/CippCADeployDrawer.jsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback } from "react"; -import { Button, Stack, Box } from "@mui/material"; +import { Button, Stack } from "@mui/material"; import { RocketLaunch } from "@mui/icons-material"; import { useForm, useWatch } from "react-hook-form"; import { CippOffCanvas } from "./CippOffCanvas"; @@ -125,6 +125,7 @@ export const CippCADeployDrawer = ({ name="tenantFilter" required={true} disableClearable={false} + preselectedEnabled={true} allTenants={true} type="multiple" /> diff --git a/src/components/CippComponents/CippCalendarPermissionsDialog.jsx b/src/components/CippComponents/CippCalendarPermissionsDialog.jsx index b8c63e2994d2..8cc9ad829ae3 100644 --- a/src/components/CippComponents/CippCalendarPermissionsDialog.jsx +++ b/src/components/CippComponents/CippCalendarPermissionsDialog.jsx @@ -66,6 +66,7 @@ const CippCalendarPermissionsDialog = ({ formHook, combinedOptions, isUserGroupL { value: "Reviewer", label: "Reviewer" }, { value: "LimitedDetails", label: "Limited Details" }, { value: "AvailabilityOnly", label: "Availability Only" }, + { value: "None", label: "None" }, ]} multiple={false} formControl={formHook} diff --git a/src/components/CippComponents/CippCodeBlock.jsx b/src/components/CippComponents/CippCodeBlock.jsx index 6dbf8f0e047f..507a26667bbd 100644 --- a/src/components/CippComponents/CippCodeBlock.jsx +++ b/src/components/CippComponents/CippCodeBlock.jsx @@ -16,7 +16,7 @@ const CodeContainer = styled("div")` padding-bottom: 1rem; .cipp-code-copy-button { position: absolute; - right: 0.5rem; + right: 1rem; /* Moved further left to avoid Monaco scrollbar */ top: 0.5rem; z-index: 1; /* Ensure the button is above the code block */ } @@ -54,7 +54,7 @@ export const CippCodeBlock = (props) => { options={{ wordWrap: true, lineNumbers: showLineNumbers ? "on" : "off", - minimap: { enabled: showLineNumbers}, + minimap: { enabled: showLineNumbers }, }} {...other} /> diff --git a/src/components/CippComponents/CippContactPermissionsDialog.jsx b/src/components/CippComponents/CippContactPermissionsDialog.jsx index f3a8b17c9b35..ee554d6f2909 100644 --- a/src/components/CippComponents/CippContactPermissionsDialog.jsx +++ b/src/components/CippComponents/CippContactPermissionsDialog.jsx @@ -58,6 +58,7 @@ const CippContactPermissionsDialog = ({ formHook, combinedOptions, isUserGroupLo { value: "Reviewer", label: "Reviewer" }, { value: "LimitedDetails", label: "Limited Details" }, { value: "AvailabilityOnly", label: "Availability Only" }, + { value: "None", label: "None" }, ]} multiple={false} formControl={formHook} diff --git a/src/components/CippComponents/CippCopyToClipboard.jsx b/src/components/CippComponents/CippCopyToClipboard.jsx index cdc18f224f8b..2be6d6aa2880 100644 --- a/src/components/CippComponents/CippCopyToClipboard.jsx +++ b/src/components/CippComponents/CippCopyToClipboard.jsx @@ -1,11 +1,40 @@ import { CopyAll, Visibility, VisibilityOff } from "@mui/icons-material"; import { Chip, IconButton, SvgIcon, Tooltip } from "@mui/material"; import { useState } from "react"; -import CopyToClipboard from "react-copy-to-clipboard"; export const CippCopyToClipBoard = (props) => { - const { text, type = "button", visible = true, ...other } = props; + const { text, type = "button", visible = true, onClick, ...other } = props; const [showPassword, setShowPassword] = useState(false); + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + if (onClick) onClick(); + } catch (err) { + console.error("Failed to copy text: ", err); + // Fallback for older browsers + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + if (onClick) onClick(); + } catch (fallbackErr) { + console.error("Fallback copy failed: ", fallbackErr); + } + } + }; const handleTogglePassword = () => { setShowPassword((prev) => !prev); @@ -15,32 +44,29 @@ export const CippCopyToClipBoard = (props) => { if (type === "button") { return ( - - - - - - - - - + + + + + + + ); } if (type === "chip") { return ( - - - - - + + + ); } @@ -52,17 +78,16 @@ export const CippCopyToClipBoard = (props) => { {showPassword ? : } - - - - - + + + > ); } diff --git a/src/components/CippComponents/CippCustomVariables.jsx b/src/components/CippComponents/CippCustomVariables.jsx index 53ec4f601013..b408deaae8ea 100644 --- a/src/components/CippComponents/CippCustomVariables.jsx +++ b/src/components/CippComponents/CippCustomVariables.jsx @@ -9,9 +9,12 @@ import { ApiPostCall } from "/src/api/ApiCall"; const CippCustomVariables = ({ id }) => { const [openAddDialog, setOpenAddDialog] = useState(false); + // Simple cache invalidation using React Query wildcard support + const allRelatedKeys = ["CustomVariables*"]; + const updateCustomVariablesApi = ApiPostCall({ urlFromData: true, - relatedQueryKeys: [`CustomVariables_${id}`], + relatedQueryKeys: allRelatedKeys, }); const reservedVariables = [ @@ -64,15 +67,25 @@ const CippCustomVariables = ({ id }) => { label: "Variable Name", placeholder: "Enter the key for the custom variable.", required: true, + disableVariables: true, validators: { validate: validateVariableName }, }, { type: "textField", name: "Value", label: "Value", + disableVariables: true, placeholder: "Enter the value for the custom variable.", required: true, }, + { + type: "textField", + name: "Description", + label: "Description", + placeholder: "Enter a description for the custom variable.", + required: false, + disableVariables: true, + }, ], type: "POST", url: "/api/ExecCippReplacemap", @@ -80,7 +93,7 @@ const CippCustomVariables = ({ id }) => { Action: "!AddEdit", tenantId: id, }, - relatedQueryKeys: [`CustomVariables_${id}`], + relatedQueryKeys: allRelatedKeys, }, { label: "Delete", @@ -93,7 +106,7 @@ const CippCustomVariables = ({ id }) => { RowKey: "RowKey", tenantId: id, }, - relatedQueryKeys: [`CustomVariables_${id}`], + relatedQueryKeys: allRelatedKeys, multiPost: false, }, ]; @@ -110,14 +123,14 @@ const CippCustomVariables = ({ id }) => { : "Custom variables are key-value pairs that can be used to store additional information about a tenant. These are applied to templates in standards using the format %variablename%."} { label: "Variable Name", placeholder: "Enter the name for the custom variable without %.", required: true, + disableVariables: true, validators: { validate: validateVariableName }, }, { type: "textField", name: "Value", label: "Value", + disableVariables: true, placeholder: "Enter the value for the custom variable.", required: true, }, + { + type: "textField", + name: "Description", + label: "Description", + placeholder: "Enter a description for the custom variable.", + required: false, + disableVariables: true, + }, ]} api={{ type: "POST", url: "/api/ExecCippReplacemap", data: { Action: "AddEdit", tenantId: id }, - relatedQueryKeys: [`CustomVariables_${id}`], + relatedQueryKeys: allRelatedKeys, }} /> diff --git a/src/components/CippComponents/CippDeployContactTemplateDrawer.jsx b/src/components/CippComponents/CippDeployContactTemplateDrawer.jsx new file mode 100644 index 000000000000..4ec2b0d533e1 --- /dev/null +++ b/src/components/CippComponents/CippDeployContactTemplateDrawer.jsx @@ -0,0 +1,141 @@ +import React, { useState, useEffect } from "react"; +import { Button, Divider } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { RocketLaunch } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormTenantSelector } from "./CippFormTenantSelector"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippDeployContactTemplateDrawer = ({ + buttonText = "Deploy Contact Template", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + selectedTenants: [], + TemplateList: [], + }, + }); + + const { isValid } = useFormState({ control: formControl.control }); + + const deployTemplate = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: ["DeployContactTemplates"], + }); + + // Reset form fields on successful creation + useEffect(() => { + if (deployTemplate.isSuccess) { + formControl.reset({ + selectedTenants: [], + TemplateList: [], + }); + } + }, [deployTemplate.isSuccess, formControl]); + + const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + + const formData = formControl.getValues(); + + deployTemplate.mutate({ + url: "/api/DeployContactTemplates", + data: formData, + relatedQueryKeys: ["DeployContactTemplates"], + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + selectedTenants: [], + TemplateList: [], + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + > + {buttonText} + + + + {deployTemplate.isLoading + ? "Deploying..." + : deployTemplate.isSuccess + ? "Deploy Another" + : "Deploy Templates"} + + + Close + + + } + > + + + + + + + + {/* TemplateList */} + + option, + url: "/api/ListContactTemplates", + }} + placeholder="Select a template or enter PowerShell JSON manually" + /> + + + + + + > + ); +}; diff --git a/src/components/CippComponents/CippFormComponent.jsx b/src/components/CippComponents/CippFormComponent.jsx index c41a7ce8ac89..af77e98ed1e1 100644 --- a/src/components/CippComponents/CippFormComponent.jsx +++ b/src/components/CippComponents/CippFormComponent.jsx @@ -13,6 +13,7 @@ import { Input, } from "@mui/material"; import { CippAutoComplete } from "./CippAutocomplete"; +import { CippTextFieldWithVariables } from "./CippTextFieldWithVariables"; import { Controller, useFormState } from "react-hook-form"; import { DateTimePicker } from "@mui/x-date-pickers"; // Make sure to install @mui/x-date-pickers import CSVReader from "../CSVReader"; @@ -52,6 +53,8 @@ export const CippFormComponent = (props) => { labelLocation = "behind", // Default location for switches defaultValue, helperText, + disableVariables = false, + includeSystemVariables = false, ...other } = props; const { errors } = useFormState({ control: formControl.control }); @@ -121,16 +124,75 @@ export const CippFormComponent = (props) => { return ( <> - + !disableVariables ? ( + + ) : ( + + ) + } + /> + + + {get(errors, convertedName, {})?.message} + + {helperText && ( + + {helperText} + + )} + > + ); + case "textFieldWithVariables": + return ( + <> + + ( + + )} /> @@ -204,11 +266,11 @@ export const CippFormComponent = (props) => { renderSwitchWithLabel( @@ -306,9 +368,11 @@ export const CippFormComponent = (props) => { )} /> - - {get(errors, convertedName, {}).message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} > ); @@ -332,9 +396,11 @@ export const CippFormComponent = (props) => { )} /> - - {get(errors, convertedName, {}).message} - + {get(errors, convertedName, {})?.message && ( + + {get(errors, convertedName, {})?.message} + + )} {helperText && ( {helperText} diff --git a/src/components/CippComponents/CippFormDomainSelector.jsx b/src/components/CippComponents/CippFormDomainSelector.jsx index 9bf5f65639d0..8d9cbea7dca2 100644 --- a/src/components/CippComponents/CippFormDomainSelector.jsx +++ b/src/components/CippComponents/CippFormDomainSelector.jsx @@ -1,6 +1,7 @@ import { CippFormComponent } from "./CippFormComponent"; import { useWatch } from "react-hook-form"; import { useSettings } from "../../hooks/use-settings"; +import { useMemo } from "react"; export const CippFormDomainSelector = ({ formControl, @@ -9,10 +10,44 @@ export const CippFormDomainSelector = ({ allTenants = false, type = "multiple", multiple = false, + preselectDefaultDomain = true, ...other }) => { const currentTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); const selectedTenant = useSettings().currentTenant; + + const apiConfig = useMemo( + () => ({ + autoSelectFirstItem: preselectDefaultDomain && !multiple, + tenantFilter: currentTenant ? currentTenant.value : selectedTenant, + queryKey: `listDomains-${currentTenant?.value ? currentTenant.value : selectedTenant}`, + url: "/api/ListGraphRequest", + dataKey: "Results", + labelField: (option) => `${option.id}`, + valueField: "id", + addedField: { + isDefault: "isDefault", + isInitial: "isInitial", + isVerified: "isVerified", + }, + data: { + Endpoint: "domains", + manualPagination: true, + $count: true, + $top: 99, + }, + dataFilter: (domains) => { + // Always sort domains so that the default domain appears first + return domains.sort((a, b) => { + if (a.addedFields?.isDefault === true) return -1; + if (b.addedFields?.isDefault === true) return 1; + return 0; + }); + }, + }), + [currentTenant, selectedTenant, preselectDefaultDomain, multiple] + ); + return ( `${option.id}`, - valueField: "id", - data: { - Endpoint: "domains", - manualPagination: true, - $count: true, - $top: 99, - }, - }} + api={apiConfig} {...other} /> ); diff --git a/src/components/CippComponents/CippFormTenantSelector.jsx b/src/components/CippComponents/CippFormTenantSelector.jsx index 42b0adca698b..0ce271515d29 100644 --- a/src/components/CippComponents/CippFormTenantSelector.jsx +++ b/src/components/CippComponents/CippFormTenantSelector.jsx @@ -33,24 +33,26 @@ export const CippFormTenantSelector = ({ const buildApiUrl = () => { const baseUrl = allTenants ? "/api/ListTenants?AllTenantSelector=true" : "/api/ListTenants"; const params = new URLSearchParams(); - + if (allTenants) { params.append("AllTenantSelector", "true"); } - + if (includeOffboardingDefaults) { params.append("IncludeOffboardingDefaults", "true"); } - - return params.toString() ? `${baseUrl.split('?')[0]}?${params.toString()}` : baseUrl.split('?')[0]; + + return params.toString() + ? `${baseUrl.split("?")[0]}?${params.toString()}` + : baseUrl.split("?")[0]; }; - + // Fetch tenant list const tenantList = ApiGetCall({ url: buildApiUrl(), - queryKey: allTenants - ? `ListTenants-FormAllTenantSelector${includeOffboardingDefaults ? '-WithOffboarding' : ''}` - : `ListTenants-FormnotAllTenants${includeOffboardingDefaults ? '-WithOffboarding' : ''}`, + queryKey: allTenants + ? `ListTenants-FormAllTenantSelector${includeOffboardingDefaults ? "-WithOffboarding" : ""}` + : `ListTenants-FormnotAllTenants${includeOffboardingDefaults ? "-WithOffboarding" : ""}`, }); // Fetch tenant group list if includeGroups is true @@ -65,26 +67,31 @@ export const CippFormTenantSelector = ({ useEffect(() => { if (tenantList.isSuccess && (!includeGroups || tenantGroupList.isSuccess)) { - const tenantData = tenantList.data.map((tenant) => ({ - value: tenant[valueField], - label: `${tenant.displayName} (${tenant.defaultDomainName})`, - type: "Tenant", - addedFields: { - defaultDomainName: tenant.defaultDomainName, - displayName: tenant.displayName, - customerId: tenant.customerId, - ...(includeOffboardingDefaults && { offboardingDefaults: tenant.offboardingDefaults }), - }, - })); - - const groupData = includeGroups - ? tenantGroupList?.data?.Results?.map((group) => ({ - value: group.Id, - label: group.Name, - type: "Group", + const tenantData = Array.isArray(tenantList.data) + ? tenantList.data.map((tenant) => ({ + value: tenant[valueField], + label: `${tenant.displayName} (${tenant.defaultDomainName})`, + type: "Tenant", + addedFields: { + defaultDomainName: tenant.defaultDomainName, + displayName: tenant.displayName, + customerId: tenant.customerId, + ...(includeOffboardingDefaults && { + offboardingDefaults: tenant.offboardingDefaults, + }), + }, })) : []; + const groupData = + includeGroups && Array.isArray(tenantGroupList?.data?.Results) + ? tenantGroupList.data.Results.map((group) => ({ + value: group.Id, + label: group.Name, + type: "Group", + })) + : []; + setOptions([...tenantData, ...groupData]); } }, [tenantList.isSuccess, tenantGroupList.isSuccess, includeGroups, includeOffboardingDefaults]); diff --git a/src/components/CippComponents/CippFormUserSelector.jsx b/src/components/CippComponents/CippFormUserSelector.jsx index 7e5b11c0f551..18a2e8d13fa9 100644 --- a/src/components/CippComponents/CippFormUserSelector.jsx +++ b/src/components/CippComponents/CippFormUserSelector.jsx @@ -13,6 +13,7 @@ export const CippFormUserSelector = ({ addedField, valueField, dataFilter = null, + showRefresh = false, ...other }) => { const currentTenant = useWatch({ control: formControl.control, name: "tenantFilter" }); @@ -47,7 +48,8 @@ export const CippFormUserSelector = ({ return options.filter(dataFilter); } return options; - } + }, + showRefresh: showRefresh, }} creatable={false} {...other} diff --git a/src/components/CippComponents/CippInviteGuestDrawer.jsx b/src/components/CippComponents/CippInviteGuestDrawer.jsx index d6818a092fbe..5c3f851206b0 100644 --- a/src/components/CippComponents/CippInviteGuestDrawer.jsx +++ b/src/components/CippComponents/CippInviteGuestDrawer.jsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { Button } from "@mui/material"; import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; +import { useForm, useFormState } from "react-hook-form"; import { Send } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; @@ -16,7 +16,7 @@ export const CippInviteGuestDrawer = ({ }) => { const [drawerVisible, setDrawerVisible] = useState(false); const userSettingsDefaults = useSettings(); - + const formControl = useForm({ mode: "onChange", defaultValues: { @@ -28,12 +28,19 @@ export const CippInviteGuestDrawer = ({ }, }); + const { isValid } = useFormState({ control: formControl.control }); + const inviteGuest = ApiPostCall({ urlFromData: true, relatedQueryKeys: [`Users-${userSettingsDefaults.currentTenant}`], }); const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } const formData = formControl.getValues(); inviteGuest.mutate({ url: "/api/AddGuest", @@ -73,7 +80,7 @@ export const CippInviteGuestDrawer = ({ variant="contained" color="primary" onClick={handleSubmit} - disabled={inviteGuest.isLoading} + disabled={inviteGuest.isLoading || !isValid} > {inviteGuest.isLoading ? "Sending Invite..." @@ -105,12 +112,12 @@ export const CippInviteGuestDrawer = ({ label="E-mail Address" name="mail" formControl={formControl} - validators={{ + validators={{ required: "Email address is required", pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, - message: "Invalid email address" - } + message: "Invalid email address", + }, }} /> @@ -139,4 +146,4 @@ export const CippInviteGuestDrawer = ({ > ); -}; \ No newline at end of file +}; diff --git a/src/components/CippComponents/CippMailboxRestoreDrawer.jsx b/src/components/CippComponents/CippMailboxRestoreDrawer.jsx new file mode 100644 index 000000000000..81e03bb4c659 --- /dev/null +++ b/src/components/CippComponents/CippMailboxRestoreDrawer.jsx @@ -0,0 +1,538 @@ +import { useEffect, useState } from "react"; +import { useForm, useWatch, useFormState } from "react-hook-form"; +import { + Button, + Drawer, + Box, + Typography, + IconButton, + Alert, + Divider, + CircularProgress, + Card, + CardContent, + Chip, + Tooltip, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { + Close as CloseIcon, + RestoreFromTrash, + DeleteForever, + Archive, + Storage, + AccountBox, +} from "@mui/icons-material"; +import { useSettings } from "../../hooks/use-settings"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import CippFormComponent from "./CippFormComponent"; +import { CippApiResults } from "./CippApiResults"; +import { ApiPostCall } from "../../api/ApiCall"; + +const wellKnownFolders = [ + "Inbox", + "SentItems", + "DeletedItems", + "Calendar", + "Contacts", + "Drafts", + "Journal", + "Tasks", + "Notes", + "JunkEmail", + "CommunicationHistory", + "Voicemail", + "Fax", + "Conflicts", + "SyncIssues", + "LocalFailures", + "ServerFailures", +].map((folder) => ({ value: `#${folder}#`, label: getCippTranslation(folder) })); + +export const CippMailboxRestoreDrawer = ({ + buttonText = "New Restore Job", + requiredPermissions = [], + PermissionButton = Button, +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + const tenantDomain = userSettingsDefaults.currentTenant; + + const formControl = useForm({ + mode: "onBlur", + defaultValues: { + tenantFilter: tenantDomain, + }, + }); + + const createRestore = ApiPostCall({ + relatedQueryKeys: ["MailboxRestores*"], + datafromurl: true, + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + const sourceMailbox = useWatch({ control: formControl.control, name: "SourceMailbox" }); + const targetMailbox = useWatch({ control: formControl.control, name: "TargetMailbox" }); + + // Helper function to check if archive is active (GUID exists and is not all zeros) + const hasActiveArchive = (mailbox) => { + const archiveGuid = mailbox?.addedFields?.ArchiveGuid; + return ( + archiveGuid && + archiveGuid !== "00000000-0000-0000-0000-000000000000" && + archiveGuid.replace(/0/g, "").replace(/-/g, "") !== "" + ); + }; + + useEffect(() => { + if (sourceMailbox && targetMailbox) { + const sourceUPN = sourceMailbox.value; + const targetUPN = targetMailbox.value; + const randomGUID = crypto.randomUUID(); + formControl.setValue("RequestName", `Restore ${sourceUPN} to ${targetUPN} (${randomGUID})`, { + shouldDirty: true, + shouldValidate: true, + }); + } + }, [sourceMailbox?.value, targetMailbox?.value]); + + useEffect(() => { + if (createRestore.isSuccess) { + formControl.reset(); + } + }, [createRestore.isSuccess]); + + const handleSubmit = () => { + const values = formControl.getValues(); + const shippedValues = { + TenantFilter: tenantDomain, + RequestName: values.RequestName, + SourceMailbox: values.SourceMailbox?.addedFields?.ExchangeGuid ?? values.SourceMailbox?.value, + TargetMailbox: values.TargetMailbox?.addedFields?.ExchangeGuid ?? values.TargetMailbox?.value, + BadItemLimit: values.BadItemLimit, + LargeItemLimit: values.LargeItemLimit, + AcceptLargeDataLoss: values.AcceptLargeDataLoss, + AssociatedMessagesCopyOption: values.AssociatedMessagesCopyOption, + ExcludeFolders: values.ExcludeFolders, + IncludeFolders: values.IncludeFolders, + BatchName: values.BatchName, + CompletedRequestAgeLimit: values.CompletedRequestAgeLimit, + ConflictResolutionOption: values.ConflictResolutionOption, + SourceRootFolder: values.SourceRootFolder, + TargetRootFolder: values.TargetRootFolder, + TargetType: values.TargetType, + ExcludeDumpster: values.ExcludeDumpster, + SourceIsArchive: values.SourceIsArchive, + TargetIsArchive: values.TargetIsArchive, + }; + + createRestore.mutate({ + url: "/api/ExecMailboxRestore", + data: shippedValues, + }); + }; + + const handleCloseDrawer = () => { + formControl.reset(); + setDrawerVisible(false); + }; + + return ( + <> + } + onClick={() => setDrawerVisible(true)} + requiredPermissions={requiredPermissions} + > + {buttonText} + + + + + + New Mailbox Restore + + + + + + + + + + Use this form to restore a mailbox from a soft-deleted state to the target + mailbox. Use the optional settings to tailor the restore request for your needs. + + + + + Restore Settings + + + + `${option.displayName} (${option.UPN})`, + valueField: "UPN", + addedField: { + displayName: "displayName", + ExchangeGuid: "ExchangeGuid", + recipientTypeDetails: "recipientTypeDetails", + ArchiveStatus: "ArchiveStatus", + ArchiveGuid: "ArchiveGuid", + ProhibitSendQuota: "ProhibitSendQuota", + TotalItemSize: "TotalItemSize", + ItemCount: "ItemCount", + WhenSoftDeleted: "WhenSoftDeleted", + }, + url: "/api/ListMailboxes?SoftDeletedMailbox=true", + queryKey: `ListMailboxes-${tenantDomain}-SoftDeleted`, + showRefresh: true, + }} + validators={{ + validate: (value) => (value ? true : "Please select a source mailbox."), + }} + /> + + + {sourceMailbox && ( + + + {sourceMailbox.addedFields?.recipientTypeDetails && ( + + } + label={sourceMailbox.addedFields.recipientTypeDetails} + size="small" + color="info" + variant="outlined" + /> + + )} + + } + label={ + hasActiveArchive(sourceMailbox) + ? "Archive Active" + : "Archive Not Available" + } + size="small" + color={hasActiveArchive(sourceMailbox) ? "success" : "warning"} + variant="outlined" + /> + + + + )} + + + `${option.displayName} (${option.UPN})`, + valueField: "UPN", + addedField: { + displayName: "displayName", + ExchangeGuid: "ExchangeGuid", + recipientTypeDetails: "recipientTypeDetails", + ArchiveStatus: "ArchiveStatus", + ArchiveGuid: "ArchiveGuid", + ProhibitSendQuota: "ProhibitSendQuota", + TotalItemSize: "TotalItemSize", + ItemCount: "ItemCount", + }, + url: "/api/ListMailboxes", + showRefresh: true, + }} + validators={{ + validate: (value) => (value ? true : "Please select a target mailbox."), + }} + /> + + + {targetMailbox && ( + + + {targetMailbox.addedFields?.recipientTypeDetails && ( + + } + label={targetMailbox.addedFields.recipientTypeDetails} + size="small" + color="info" + variant="outlined" + /> + + )} + + } + label={ + hasActiveArchive(targetMailbox) + ? "Archive Active" + : "Archive Not Available" + } + size="small" + color={hasActiveArchive(targetMailbox) ? "success" : "warning"} + variant="outlined" + /> + + {targetMailbox.addedFields?.TotalItemSize && ( + + } + label={targetMailbox.addedFields.TotalItemSize} + size="small" + color="info" + variant="outlined" + /> + + )} + + + )} + + + + + + + + + + + Optional Settings + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cancel + + : + } + > + {createRestore.isPending ? "Creating..." : "Create Restore Job"} + + + + + + > + ); +}; diff --git a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx index 4ddad43c7aff..9ac089dee39b 100644 --- a/src/components/CippComponents/CippOffboardingDefaultSettings.jsx +++ b/src/components/CippComponents/CippOffboardingDefaultSettings.jsx @@ -185,6 +185,16 @@ export const CippOffboardingDefaultSettings = (props) => { /> ), }, + { + label: "Remove Teams Phone DID", + value: ( + + ), + }, { label: "Clear Immutable ID", value: ( diff --git a/src/components/CippComponents/CippPolicyDeployDrawer.jsx b/src/components/CippComponents/CippPolicyDeployDrawer.jsx index 6b9635fe5bb4..3702111ed47f 100644 --- a/src/components/CippComponents/CippPolicyDeployDrawer.jsx +++ b/src/components/CippComponents/CippPolicyDeployDrawer.jsx @@ -1,9 +1,8 @@ import { useEffect, useState } from "react"; import { Button, Stack, Box } from "@mui/material"; import { RocketLaunch } from "@mui/icons-material"; -import { useForm, useWatch } from "react-hook-form"; +import { useForm, useWatch, useFormState } from "react-hook-form"; import { CippOffCanvas } from "./CippOffCanvas"; -import { CippIntunePolicy } from "../CippWizard/CippIntunePolicy"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import CippFormComponent from "./CippFormComponent"; import CippJsonView from "../CippFormPages/CippJSONView"; @@ -19,7 +18,10 @@ export const CippPolicyDeployDrawer = ({ PermissionButton = Button, }) => { const [drawerVisible, setDrawerVisible] = useState(false); - const formControl = useForm(); + const formControl = useForm({ + mode: "onChange", + }); + const { isValid } = useFormState({ control: formControl.control }); const tenantFilter = useSettings()?.tenantFilter; const selectedTenants = useWatch({ control: formControl.control, name: "tenantFilter" }) || []; const CATemplates = ApiGetCall({ url: "/api/ListIntuneTemplates", queryKey: "IntuneTemplates" }); @@ -50,6 +52,12 @@ export const CippPolicyDeployDrawer = ({ }); const handleSubmit = () => { + formControl.trigger(); + // Check if the form is valid before proceeding + if (!isValid) { + return; + } + const formData = formControl.getValues(); console.log("Submitting form data:", formData); deployPolicy.mutate({ @@ -89,7 +97,7 @@ export const CippPolicyDeployDrawer = ({ variant="contained" color="primary" onClick={handleSubmit} - disabled={deployPolicy.isLoading} + disabled={deployPolicy.isLoading || !isValid} > {deployPolicy.isLoading ? "Deploying..." diff --git a/src/components/CippComponents/CippPolicyImportDrawer.jsx b/src/components/CippComponents/CippPolicyImportDrawer.jsx index 9ba29343b7da..bbe5b3a03dc4 100644 --- a/src/components/CippComponents/CippPolicyImportDrawer.jsx +++ b/src/components/CippComponents/CippPolicyImportDrawer.jsx @@ -47,6 +47,8 @@ export const CippPolicyImportDrawer = ({ url: mode === "ConditionalAccess" ? `/api/ListCATemplates?TenantFilter=${tenantFilter?.value || ""}` + : mode === "Standards" + ? `/api/listStandardTemplates?TenantFilter=${tenantFilter?.value || ""}` : `/api/ListIntunePolicy?type=ESP&TenantFilter=${tenantFilter?.value || ""}`, queryKey: `TenantPolicies-${mode}-${tenantFilter?.value || "none"}`, }); @@ -72,6 +74,8 @@ export const CippPolicyImportDrawer = ({ relatedQueryKeys: mode === "ConditionalAccess" ? ["ListCATemplates-table"] + : mode === "Standards" + ? ["listStandardTemplates"] : ["ListIntuneTemplates-table", "ListIntuneTemplates-autcomplete"], }); @@ -121,6 +125,16 @@ export const CippPolicyImportDrawer = ({ url: "/api/AddCATemplate", data: caTemplateData, }); + } else if (mode === "Standards") { + // For Standards templates, clone the template + importPolicy.mutate({ + url: "/api/AddStandardTemplate", + data: { + tenantFilter: tenantFilter?.value, + templateId: policy.GUID, + clone: true, + }, + }); } else { // For Intune policies, use existing format importPolicy.mutate({ @@ -486,7 +500,7 @@ export const CippPolicyImportDrawer = ({ ) : ( )} diff --git a/src/components/CippComponents/CippRestoreBackupDrawer.jsx b/src/components/CippComponents/CippRestoreBackupDrawer.jsx new file mode 100644 index 000000000000..f011b499820f --- /dev/null +++ b/src/components/CippComponents/CippRestoreBackupDrawer.jsx @@ -0,0 +1,373 @@ +import React, { useState, useEffect } from "react"; +import { Button, Box, Typography, Alert, AlertTitle, Divider, Chip, Stack } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useFormState } from "react-hook-form"; +import { SettingsBackupRestore } from "@mui/icons-material"; +import { CippOffCanvas } from "./CippOffCanvas"; +import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; +import { CippApiResults } from "./CippApiResults"; +import { useSettings } from "../../hooks/use-settings"; +import { ApiPostCall } from "../../api/ApiCall"; + +export const CippRestoreBackupDrawer = ({ + buttonText = "Restore Backup", + backupName = null, + requiredPermissions = [], + PermissionButton = Button, + ...props +}) => { + const [drawerVisible, setDrawerVisible] = useState(false); + const userSettingsDefaults = useSettings(); + const tenantFilter = userSettingsDefaults.currentTenant || ""; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: tenantFilter, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + CippStandards: true, + overwrite: false, + webhook: false, + email: false, + psa: false, + backup: backupName ? { value: backupName, label: backupName } : null, + }, + }); + + const restoreBackup = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`BackupTasks-${tenantFilter}`], + }); + + const { isValid, isDirty } = useFormState({ control: formControl.control }); + + useEffect(() => { + if (restoreBackup.isSuccess) { + formControl.reset({ + tenantFilter: tenantFilter, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + CippStandards: true, + overwrite: false, + webhook: false, + email: false, + psa: false, + backup: backupName ? { value: backupName, label: backupName } : null, + }); + } + }, [restoreBackup.isSuccess]); + + const handleSubmit = () => { + formControl.trigger(); + if (!isValid) { + return; + } + const values = formControl.getValues(); + const startDate = new Date(); + const unixTime = Math.floor(startDate.getTime() / 1000) - 45; + const tenantFilterValue = tenantFilter; + + const shippedValues = { + TenantFilter: tenantFilterValue, + Name: `CIPP Restore ${tenantFilterValue}`, + Command: { value: `New-CIPPRestore` }, + Parameters: { + Type: "Scheduled", + RestoreValues: { + backup: values.backup?.value || values.backup, + users: values.users, + groups: values.groups, + ca: values.ca, + intuneconfig: values.intuneconfig, + intunecompliance: values.intunecompliance, + intuneprotection: values.intuneprotection, + antispam: values.antispam, + antiphishing: values.antiphishing, + CippWebhookAlerts: values.CippWebhookAlerts, + CippScriptedAlerts: values.CippScriptedAlerts, + CippCustomVariables: values.CippCustomVariables, + overwrite: values.overwrite, + }, + }, + ScheduledTime: unixTime, + PostExecution: { + Webhook: values.webhook, + Email: values.email, + PSA: values.psa, + }, + DisallowDuplicateName: true, + }; + + restoreBackup.mutate({ + url: "/api/AddScheduledItem", + data: shippedValues, + }); + }; + + const handleCloseDrawer = () => { + setDrawerVisible(false); + formControl.reset({ + tenantFilter: tenantFilter, + users: true, + groups: true, + ca: true, + intuneconfig: true, + intunecompliance: true, + intuneprotection: true, + antispam: true, + antiphishing: true, + CippWebhookAlerts: true, + CippScriptedAlerts: true, + CippCustomVariables: true, + CippStandards: true, + overwrite: false, + webhook: false, + email: false, + psa: false, + backup: backupName ? { value: backupName, label: backupName } : null, + }); + }; + + return ( + <> + setDrawerVisible(true)} + startIcon={} + {...props} + > + {buttonText} + + + + {restoreBackup.isPending + ? "Creating Restore Task..." + : restoreBackup.isSuccess + ? "Create Another Restore Task" + : "Restore Backup"} + + + Close + + + } + > + + + Use this form to restore a backup for a tenant. Please select the backup and restore + options. + + + + {/* Backup Selector */} + + { + const match = option.BackupName.match(/.*_(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})/); + return match ? `${match[1]} @ ${match[2]}:${match[3]}` : option.BackupName; + }, + valueField: "BackupName", + data: { + Type: "Scheduled", + NameOnly: true, + tenantFilter: tenantFilter, + }, + }} + formControl={formControl} + required={true} + validators={{ + validate: (value) => !!value || "Please select a backup", + }} + /> + + + {/* Restore Settings */} + + Restore Settings + + + {/* Identity */} + + Identity + + + + + {/* Conditional Access */} + + Conditional Access + + + + {/* Intune */} + + Intune + + + + + + {/* Email Security */} + + Email Security + + + + + {/* CIPP */} + + CIPP + + + + + + {/* Overwrite Existing Entries */} + + + + + + Warning: Overwriting existing entries will remove the current + settings and replace them with the backup settings. If you have selected to + restore users, all properties will be overwritten with the backup settings. To + prevent and skip already existing entries, deselect the setting from the list + above, or disable overwrite. + + + + + + {/* Send Results To */} + + Send Restore results to: + + + + + + + + + + + + + + + > + ); +}; diff --git a/src/components/CippComponents/CippSettingsSideBar.jsx b/src/components/CippComponents/CippSettingsSideBar.jsx index 03621be6b25f..693c55673d77 100644 --- a/src/components/CippComponents/CippSettingsSideBar.jsx +++ b/src/components/CippComponents/CippSettingsSideBar.jsx @@ -96,6 +96,7 @@ export const CippSettingsSideBar = (props) => { RemoveMobile: formValues.offboardingDefaults?.RemoveMobile, DisableSignIn: formValues.offboardingDefaults?.DisableSignIn, RemoveMFADevices: formValues.offboardingDefaults?.RemoveMFADevices, + RemoveTeamsPhoneDID: formValues.offboardingDefaults?.RemoveTeamsPhoneDID, ClearImmutableId: formValues.offboardingDefaults?.ClearImmutableId, }, }; diff --git a/src/components/CippComponents/CippTableDialog.jsx b/src/components/CippComponents/CippTableDialog.jsx index 59ec73436efd..e31d4485263b 100644 --- a/src/components/CippComponents/CippTableDialog.jsx +++ b/src/components/CippComponents/CippTableDialog.jsx @@ -1,28 +1,30 @@ -import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; -import { Stack } from "@mui/system"; -import { CippDataTable } from "../CippTable/CippDataTable"; - -export const CippTableDialog = (props) => { - const { createDialog, title, fields, api, simpleColumns, ...other } = props; - return ( - - {title} - - - - - - - - Close - - - - ); -}; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Stack } from "@mui/system"; +import { CippDataTable } from "../CippTable/CippDataTable"; + +export const CippTableDialog = (props) => { + const { createDialog, title, fields, api, simpleColumns, ...other } = props; + + return ( + + {title} + + + + + + + + Close + + + + ); +}; diff --git a/src/components/CippComponents/CippTemplateFieldRenderer.jsx b/src/components/CippComponents/CippTemplateFieldRenderer.jsx index 9db3cfe458f8..7fae93ec413f 100644 --- a/src/components/CippComponents/CippTemplateFieldRenderer.jsx +++ b/src/components/CippComponents/CippTemplateFieldRenderer.jsx @@ -341,6 +341,7 @@ const CippTemplateFieldRenderer = ({ name={`${fieldPath}.settings.${index}.settingInstance.simpleSettingValue.value`} formControl={formControl} helperText={`Definition ID: ${settingInstance.settingDefinitionId}`} + includeSystemVariables={true} /> ); @@ -416,6 +417,7 @@ const CippTemplateFieldRenderer = ({ label="Value" name={`${fieldPath}.omaSettings.${index}.value`} formControl={formControl} + includeSystemVariables={true} /> @@ -510,6 +512,7 @@ const CippTemplateFieldRenderer = ({ label={`${getCippTranslation(key)} ${index + 1}`} name={`${fieldPath}.${index}`} formControl={formControl} + includeSystemVariables={true} /> )} @@ -623,6 +626,7 @@ const CippTemplateFieldRenderer = ({ label={getCippTranslation(key)} name={fieldPath} formControl={formControl} + includeSystemVariables={true} /> ); @@ -668,6 +672,7 @@ const CippTemplateFieldRenderer = ({ label={getCippTranslation(key)} name={fieldPath} formControl={formControl} + includeSystemVariables={true} /> ); diff --git a/src/components/CippComponents/CippTextFieldWithVariables.jsx b/src/components/CippComponents/CippTextFieldWithVariables.jsx new file mode 100644 index 000000000000..0f6abd9fea99 --- /dev/null +++ b/src/components/CippComponents/CippTextFieldWithVariables.jsx @@ -0,0 +1,214 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { TextField } from "@mui/material"; +import { CippVariableAutocomplete } from "./CippVariableAutocomplete"; +import { useSettings } from "/src/hooks/use-settings.js"; + +/** + * Enhanced TextField that supports custom variable autocomplete + * Triggers when user types % character + */ +export const CippTextFieldWithVariables = ({ + value = "", + onChange, + includeSystemVariables = false, + ...textFieldProps +}) => { + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompleteAnchor, setAutocompleteAnchor] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [cursorPosition, setCursorPosition] = useState(0); + const textFieldRef = useRef(null); + + const settings = useSettings(); + // Memoize tenant filter to prevent unnecessary re-renders + const tenantFilter = useMemo(() => settings?.currentTenant || null, [settings?.currentTenant]); + + // Safely close autocomplete + const closeAutocomplete = useCallback(() => { + setShowAutocomplete(false); + setSearchQuery(""); + setAutocompleteAnchor(null); + }, []); + + // Track cursor position + const handleSelectionChange = () => { + if (textFieldRef.current) { + setCursorPosition(textFieldRef.current.selectionStart || 0); + } + }; + + // Get cursor position for floating autocomplete + const getCursorPosition = () => { + if (!textFieldRef.current) return { top: 0, left: 0 }; + + const rect = textFieldRef.current.getBoundingClientRect(); + return { + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX + cursorPosition * 8, // Approximate character width + }; + }; + + // Handle input changes and detect % trigger + const handleInputChange = (event) => { + const newValue = event.target.value; + const cursorPos = event.target.selectionStart; + + // Update cursor position state immediately + setCursorPosition(cursorPos); + + // Call parent onChange + if (onChange) { + onChange(event); + } + + // Check if % was just typed + if (newValue[cursorPos - 1] === "%") { + // Position autocomplete near cursor + setAutocompleteAnchor(textFieldRef.current); + setSearchQuery(""); + setShowAutocomplete(true); + } else if (showAutocomplete) { + // Update search query if autocomplete is open + const lastPercentIndex = newValue.lastIndexOf("%", cursorPos - 1); + if (lastPercentIndex !== -1) { + const query = newValue.substring(lastPercentIndex + 1, cursorPos); + setSearchQuery(query); + + // Close autocomplete if user typed space or special characters (except %) + if (query.includes(" ") || /[^a-zA-Z0-9_]/.test(query)) { + closeAutocomplete(); + } + } else { + closeAutocomplete(); + } + } + }; + + // Handle variable selection + const handleVariableSelect = useCallback( + (variableString) => { + if (!onChange) { + return; + } + + // Use the value prop instead of DOM value since we're in a controlled component + const currentValue = value || ""; + + // Get fresh cursor position from the DOM + let cursorPos = cursorPosition; + if (textFieldRef.current) { + const inputElement = textFieldRef.current.querySelector("input") || textFieldRef.current; + if (inputElement && typeof inputElement.selectionStart === "number") { + cursorPos = inputElement.selectionStart; + } + } + + // Find the % that triggered the autocomplete + const lastPercentIndex = currentValue.lastIndexOf("%", cursorPos - 1); + + if (lastPercentIndex !== -1) { + // Replace from % to cursor position with the selected variable + const beforePercent = currentValue.substring(0, lastPercentIndex); + const afterCursor = currentValue.substring(cursorPos); + const newValue = beforePercent + variableString + afterCursor; + + // Create synthetic event for onChange + const syntheticEvent = { + target: { + name: textFieldRef.current?.name || "", + value: newValue, + }, + }; + + onChange(syntheticEvent); + + // Set cursor position after the inserted variable + setTimeout(() => { + if (textFieldRef.current) { + const newCursorPos = lastPercentIndex + variableString.length; + + // Access the actual input element for Material-UI TextField + const inputElement = + textFieldRef.current.querySelector("input") || textFieldRef.current; + if (inputElement && inputElement.setSelectionRange) { + inputElement.setSelectionRange(newCursorPos, newCursorPos); + inputElement.focus(); + } + setCursorPosition(newCursorPos); + } + }, 0); + } + + closeAutocomplete(); + }, + [value, cursorPosition, onChange, closeAutocomplete] + ); + + // Handle key events + const handleKeyDown = (event) => { + if (showAutocomplete) { + // Let the autocomplete handle arrow keys and enter + if (["ArrowDown", "ArrowUp", "Enter", "Tab"].includes(event.key)) { + return; // Let autocomplete handle these + } + + // Close autocomplete on Escape + if (event.key === "Escape") { + closeAutocomplete(); + event.preventDefault(); + } + } + + // Call original onKeyDown if provided + if (textFieldProps.onKeyDown) { + textFieldProps.onKeyDown(event); + } + }; + + // Close autocomplete when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if ( + showAutocomplete && + textFieldRef.current && + !textFieldRef.current.contains(event.target) + ) { + // Check if click is on autocomplete dropdown + const autocompleteElement = document.querySelector("[data-cipp-autocomplete]"); + if (autocompleteElement && autocompleteElement.contains(event.target)) { + return; // Don't close if clicking inside autocomplete + } + + closeAutocomplete(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showAutocomplete]); + + return ( + <> + + + + > + ); +}; diff --git a/src/components/CippComponents/CippTranslations.jsx b/src/components/CippComponents/CippTranslations.jsx index 6ab449050f2e..99d46a6e5182 100644 --- a/src/components/CippComponents/CippTranslations.jsx +++ b/src/components/CippComponents/CippTranslations.jsx @@ -51,4 +51,5 @@ export const CippTranslations = { sendtoIntegration: "Send Notifications to Integration", includeTenantId: "Include Tenant ID in Notifications", logsToInclude: "Logs to Include in notifications", + assignmentFilterManagementType: "Filter Type", }; diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index d08c19ca69d0..b9fa60286717 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -31,7 +31,7 @@ const OutOfOfficeForm = ({ formControl }) => { // Watch the Auto Reply State value const autoReplyState = useWatch({ control: formControl.control, - name: "ooo.AutoReplyState", + name: "AutoReplyState", }); // Calculate if date fields should be disabled diff --git a/src/components/CippComponents/CippVariableAutocomplete.jsx b/src/components/CippComponents/CippVariableAutocomplete.jsx new file mode 100644 index 000000000000..9910e9771afd --- /dev/null +++ b/src/components/CippComponents/CippVariableAutocomplete.jsx @@ -0,0 +1,342 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { + Paper, + Typography, + Box, + Chip, + Popper, + ListItem, + useTheme, + CircularProgress, +} from "@mui/material"; +import { ApiGetCall } from "/src/api/ApiCall"; +import { useSettings } from "/src/hooks/use-settings.js"; +import { getCippError } from "/src/utils/get-cipp-error"; + +/** + * Autocomplete component specifically for custom variables + * Shows when user types % in a text field + */ +export const CippVariableAutocomplete = React.memo( + ({ + open, + anchorEl, + onClose, + onSelect, + searchQuery = "", + tenantFilter = null, + includeSystemVariables = false, + position = { top: 0, left: 0 }, // Cursor position for floating box + }) => { + const theme = useTheme(); + const settings = useSettings(); + + // State management similar to CippAutocomplete + const [variables, setVariables] = useState([]); + const [getRequestInfo, setGetRequestInfo] = useState({ url: "", waiting: false, queryKey: "" }); + const [filteredVariables, setFilteredVariables] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); // For keyboard navigation + + // Get current tenant like CippAutocomplete does + const currentTenant = tenantFilter || settings.currentTenant; + + // API call using the same pattern as CippAutocomplete + const actionGetRequest = ApiGetCall({ + ...getRequestInfo, + }); + + // Setup API request when component mounts or tenant changes + useEffect(() => { + if (open) { + // Normalize tenant filter + const normalizedTenantFilter = currentTenant === "AllTenants" ? null : currentTenant; + + // Build API URL + let apiUrl = "/api/ListCustomVariables"; + const params = new URLSearchParams(); + + if (normalizedTenantFilter) { + params.append("tenantFilter", normalizedTenantFilter); + } + + if (!includeSystemVariables) { + params.append("includeSystem", "false"); + } + + if (params.toString()) { + apiUrl += `?${params.toString()}`; + } + + // Generate query key + const queryKey = `CustomVariables-${normalizedTenantFilter || "global"}-${ + includeSystemVariables ? "withSystem" : "noSystem" + }`; + + setGetRequestInfo({ + url: apiUrl, + waiting: true, + queryKey: queryKey, + staleTime: Infinity, // Never goes stale like in the updated hook + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); + } + }, [open, currentTenant, includeSystemVariables]); + + // Process API response like CippAutocomplete does + useEffect(() => { + if (actionGetRequest.isSuccess && actionGetRequest.data?.Results) { + const processedVariables = actionGetRequest.data.Results.map((variable) => ({ + // Core properties + name: variable.Name, + variable: variable.Variable, + label: variable.Variable, // What shows in autocomplete + value: variable.Variable, // What gets inserted + + // Metadata for display and filtering + description: variable.Description, + type: variable.Type, // 'reserved' or 'custom' + category: variable.Category, // 'system', 'tenant', 'partner', 'cipp', 'global', 'tenant-custom' + + // Custom variable specific + ...(variable.Type === "custom" && { + customValue: variable.Value, + scope: variable.Scope, + }), + + // For grouping in autocomplete + group: + variable.Type === "reserved" + ? `Reserved (${variable.Category})` + : variable.category === "global" + ? "Global Custom Variables" + : "Tenant Custom Variables", + })); + + setVariables(processedVariables); + } + + if (actionGetRequest.isError) { + setVariables([ + { + label: getCippError(actionGetRequest.error), + value: "error", + name: "error", + variable: "error", + description: "Error loading variables", + }, + ]); + } + }, [actionGetRequest.isSuccess, actionGetRequest.isError, actionGetRequest.data]); + + // Filter variables based on search query + useEffect(() => { + if (!searchQuery) { + setFilteredVariables(variables); + setSelectedIndex(0); // Reset selection when filtering + return; + } + + const lowerQuery = searchQuery.toLowerCase(); + const filtered = variables.filter( + (variable) => + variable.name?.toLowerCase().includes(lowerQuery) || + variable.description?.toLowerCase().includes(lowerQuery) + ); + setFilteredVariables(filtered); + setSelectedIndex(0); // Reset selection when filtering + }, [searchQuery, variables]); + + const handleSelect = (event, value) => { + if (value && onSelect) { + onSelect(value.variable); // Pass the full variable string like %tenantname% + } + onClose(); + }; + + // Keyboard navigation handlers + const handleKeyDown = useCallback( + (event) => { + if (!open || filteredVariables.length === 0) return; + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setSelectedIndex((prev) => (prev < filteredVariables.length - 1 ? prev + 1 : 0)); + break; + case "ArrowUp": + event.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : filteredVariables.length - 1)); + break; + case "Tab": + case "Enter": + event.preventDefault(); + if (filteredVariables[selectedIndex]) { + handleSelect(event, filteredVariables[selectedIndex]); + } + break; + case "Escape": + event.preventDefault(); + onClose(); + break; + } + }, + [open, filteredVariables, selectedIndex, onClose] + ); + + // Set up keyboard event listeners + useEffect(() => { + if (open) { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + } + }, [open, handleKeyDown]); + + if (!open) { + return null; + } + + // Show loading state like CippAutocomplete + if (actionGetRequest.isLoading && (!variables || variables.length === 0)) { + return ( + + + + + + Loading variables... + + + + + ); + } + + if (!variables || variables.length === 0) { + return null; + } + + if (filteredVariables.length === 0) { + return null; + } + + return ( + + { + e.stopPropagation(); + }} + > + {filteredVariables.map((variable, index) => ( + { + // Scroll selected item into view + if (el) { + el.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + } + : null + } + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + handleSelect(e, variable); + }} + sx={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + py: 1, + px: 2, + borderBottom: `1px solid ${theme.palette.divider}`, + backgroundColor: + index === selectedIndex ? theme.palette.action.selected : "transparent", + borderLeft: + index === selectedIndex + ? `3px solid ${theme.palette.primary.main}` + : "3px solid transparent", + "&:hover": { + backgroundColor: theme.palette.action.hover, + }, + cursor: "pointer", + }} + > + + + {variable.variable} + + + {variable.description} + + + + + + + + ))} + + + ); + } +); diff --git a/src/components/CippFormPages/CippAddAssignmentFilterForm.jsx b/src/components/CippFormPages/CippAddAssignmentFilterForm.jsx new file mode 100644 index 000000000000..f94cd59b3780 --- /dev/null +++ b/src/components/CippFormPages/CippAddAssignmentFilterForm.jsx @@ -0,0 +1,101 @@ +import { useEffect } from "react"; +import "@mui/material"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; + +const CippAddAssignmentFilterForm = (props) => { + const { formControl, isEdit = false } = props; + + useEffect(() => { + const subscription = formControl.watch((value, { name, type }) => {}); + return () => subscription.unsubscribe(); + }, [formControl]); + + return ( + + + + + + + + + + + + + + + + + + + Enter the filter rule using Intune filter syntax. See{" "} + + Microsoft documentation + {" "} + for supported properties and operators. + > + } + required + multiline + rows={6} + fullWidth + /> + + + ); +}; + +export default CippAddAssignmentFilterForm; diff --git a/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx b/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx new file mode 100644 index 000000000000..3fb22fa45306 --- /dev/null +++ b/src/components/CippFormPages/CippAddAssignmentFilterTemplateForm.jsx @@ -0,0 +1,100 @@ +import { useEffect } from "react"; +import "@mui/material"; +import { Grid } from "@mui/system"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; + +const CippAddAssignmentFilterTemplateForm = (props) => { + const { formControl } = props; + + useEffect(() => { + const subscription = formControl.watch((value, { name, type }) => {}); + return () => subscription.unsubscribe(); + }, [formControl]); + + return ( + + {/* Hidden field to store the template GUID when editing */} + + + + + + + + + + + + + + + + + + + + Enter the filter rule using Intune filter syntax. See{" "} + + Microsoft documentation + {" "} + for supported properties and operators. + > + } + required + multiline + rows={6} + fullWidth + /> + + + ); +}; + +export default CippAddAssignmentFilterTemplateForm; diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index fbe3f7537b30..fe5a49fb1581 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -1,4 +1,4 @@ -import { Alert, InputAdornment, Typography } from "@mui/material"; +import { Alert, Divider, InputAdornment, Typography } from "@mui/material"; import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; import { CippFormCondition } from "/src/components/CippComponents/CippFormCondition"; import { CippFormDomainSelector } from "/src/components/CippComponents/CippFormDomainSelector"; @@ -42,6 +42,22 @@ const CippAddEditUser = (props) => { waiting: !!userId, }); + // Get manual entry custom data mappings for current tenant + const manualEntryMappings = ApiGetCall({ + url: `/api/ListCustomDataMappings?sourceType=Manual Entry&directoryObject=User&tenantFilter=${tenantDomain}`, + queryKey: `ManualEntryMappings-${tenantDomain}`, + refetchOnMount: false, + refetchOnReconnect: false, + }); + + // Use mappings directly since they're already filtered by the API + const currentTenantManualMappings = useMemo(() => { + if (manualEntryMappings.isSuccess) { + return manualEntryMappings.data?.Results || []; + } + return []; + }, [manualEntryMappings.isSuccess, manualEntryMappings.data]); + // Make new list of groups by removing userGroups from tenantGroups const filteredTenantGroups = useMemo(() => { if (tenantGroups.isSuccess && userGroups.isSuccess) { @@ -410,51 +426,96 @@ const CippAddEditUser = (props) => { /> )} + {/* Manual Entry Custom Data Fields */} + {currentTenantManualMappings.length > 0 && ( + <> + + Custom Data + + {currentTenantManualMappings.map((mapping, index) => { + const fieldName = `customData.${mapping.customDataAttribute.value}`; + const fieldLabel = mapping.manualEntryFieldLabel; + const dataType = mapping.customDataAttribute.addedFields.dataType; + + // Determine field type based on the custom data attribute type + const getFieldType = (dataType) => { + switch (dataType?.toLowerCase()) { + case "boolean": + return "switch"; + case "datetime": + case "date": + return "datePicker"; + case "string": + default: + return "textField"; + } + }; + + return ( + + + + ); + })} + > + )} {/* Schedule User Creation */} {formType === "add" && ( - - - - - Scheduled creation Date - - - - - - - - - + <> + + + + + + + + Scheduled creation Date + + + + + + + + + + > )} ); diff --git a/src/components/CippFormPages/CippCustomDataMappingForm.jsx b/src/components/CippFormPages/CippCustomDataMappingForm.jsx index bf595e701e97..acb0c7134a56 100644 --- a/src/components/CippFormPages/CippCustomDataMappingForm.jsx +++ b/src/components/CippFormPages/CippCustomDataMappingForm.jsx @@ -11,6 +11,7 @@ import { getCippTranslation } from "/src/utils/get-cipp-translation"; const CippCustomDataMappingForm = ({ formControl }) => { const selectedAttribute = useWatch({ control: formControl.control, name: "customDataAttribute" }); + const selectedDirectoryObjectType = useWatch({ control: formControl.control, name: "directoryObjectType", @@ -19,19 +20,35 @@ const CippCustomDataMappingForm = ({ formControl }) => { control: formControl.control, name: "extensionSyncDataset", }); + const selectedSourceType = useWatch({ + control: formControl.control, + name: "sourceType", + }); + const selectedManualEntryFieldLabel = useWatch({ + control: formControl.control, + name: "manualEntryFieldLabel", + }); + + console.log("Selected directory object type: ", selectedDirectoryObjectType); const staticTargetTypes = [{ value: "user", label: "User" }]; + // Top-level source type selection + const sourceTypeField = { + name: "sourceType", + label: "Source Type", + type: "autoComplete", + required: true, + multiple: false, + placeholder: "Select a Source Type", + options: [ + { value: "extensionSync", label: "Extension Sync" }, + { value: "manualEntry", label: "Manual Entry" }, + ], + }; + + // Extension Sync specific fields const sourceFields = [ - { - name: "sourceType", - label: "Source Type", - type: "autoComplete", - required: true, - multiple: false, - placeholder: "Select a Source Type", - options: [{ value: "extensionSync", label: "Extension Sync" }], - }, { name: "extensionSyncDataset", label: "Extension Sync Dataset", @@ -77,6 +94,59 @@ const CippCustomDataMappingForm = ({ formControl }) => { }, ]; + // Manual Entry specific fields + const manualEntryFields = [ + { + name: "manualEntryFieldLabel", + label: "Field Label", + type: "textField", + required: true, + placeholder: "Enter field label (e.g., Employee ID, Department)", + disableVariables: true, + }, + { + name: "directoryObjectType", + label: "Directory Object Type", + type: "autoComplete", + required: true, + placeholder: "Select an Object Type", + options: staticTargetTypes, + multiple: false, + creatable: false, + }, + { + name: "customDataAttribute", + label: "Attribute", + type: "autoComplete", + required: true, + placeholder: "Select an Attribute", + api: { + url: "/api/ExecCustomData?Action=ListAvailableAttributes", + queryKey: "CustomAttributes", + dataKey: "Results", + dataFilter: (options) => { + if (!selectedDirectoryObjectType?.value) return options; + return options.filter( + (option) => + option?.addedFields?.targetObject?.toLowerCase() === + selectedDirectoryObjectType?.value?.toLowerCase() + ); + }, + valueField: "name", + labelField: "name", + showRefresh: true, + addedField: { + type: "type", + targetObject: "targetObject", + dataType: "dataType", + isMultiValued: "isMultiValued", + }, + }, + multiple: false, + sortOptions: true, + }, + ]; + const destinationFields = [ { name: "directoryObjectType", @@ -143,44 +213,76 @@ const CippCustomDataMappingForm = ({ formControl }) => { - Source Details + Source Type - {sourceFields.map((field, index) => ( - <> - {field?.condition ? ( - - - - ) : ( - - )} - > - ))} - - - - - Destination Details - - {destinationFields.map((field, index) => ( - <> - {field?.condition ? ( - - - - ) : ( - - )} - > - ))} + + + {selectedSourceType?.value === "extensionSync" && ( + <> + + + Source Details + + {sourceFields.map((field, index) => ( + <> + {field?.condition ? ( + + + + ) : ( + + )} + > + ))} + + + + + Destination Details + + {destinationFields.map((field, index) => ( + <> + {field?.condition ? ( + + + + ) : ( + + )} + > + ))} + + + > + )} + + {selectedSourceType?.value === "manualEntry" && ( + + + Manual Entry Configuration + + {manualEntryFields.map((field, index) => ( + + ))} + + + )} - {selectedExtensionSyncDataset && ( + {selectedExtensionSyncDataset && selectedSourceType?.value === "extensionSync" && ( { /> )} + {selectedSourceType?.value === "manualEntry" && selectedManualEntryFieldLabel && ( + + )} + {selectedAttribute && ( { ); }; -export default CippCustomDataMappingForm; \ No newline at end of file +export default CippCustomDataMappingForm; diff --git a/src/components/CippFormPages/CippJSONView.jsx b/src/components/CippFormPages/CippJSONView.jsx index 2cd814d427b7..5f3de20a24f7 100644 --- a/src/components/CippFormPages/CippJSONView.jsx +++ b/src/components/CippFormPages/CippJSONView.jsx @@ -286,9 +286,11 @@ function CippJsonView({ let value; if (child.choiceSettingValue && child.choiceSettingValue.value) { value = - childIntuneObj?.options?.find( - (option) => option.id === child.choiceSettingValue.value - )?.displayName || child.choiceSettingValue.value; + (Array.isArray(childIntuneObj?.options) && + childIntuneObj.options.find( + (option) => option.id === child.choiceSettingValue.value + )?.displayName) || + child.choiceSettingValue.value; } items.push( option.id === rawValue)?.displayName || rawValue; + (Array.isArray(intuneObj?.options) && + intuneObj.options.find((option) => option.id === rawValue)?.displayName) || + rawValue; // Check if optionValue is a GUID that we've resolved if (typeof optionValue === "string" && isGuid(optionValue) && guidMapping[optionValue]) { diff --git a/src/components/CippFormPages/CippSchedulerForm.jsx b/src/components/CippFormPages/CippSchedulerForm.jsx index aa8350f3a0f1..049a554b8626 100644 --- a/src/components/CippFormPages/CippSchedulerForm.jsx +++ b/src/components/CippFormPages/CippSchedulerForm.jsx @@ -403,12 +403,14 @@ const CippSchedulerForm = (props) => { name={`parameters.${param.Name}`} label={param.Name} formControl={formControl} + helperText={param.Description} /> ) : param.Type === "System.Collections.Hashtable" ? ( ) : param.Type?.startsWith("System.String") ? ( @@ -418,6 +420,7 @@ const CippSchedulerForm = (props) => { label={param.Name} formControl={formControl} placeholder={`Enter a value for ${param.Name}`} + helperText={param.Description} validators={fieldRequired(param)} required={param.Required} /> @@ -428,6 +431,7 @@ const CippSchedulerForm = (props) => { label={param.Name} formControl={formControl} placeholder={`Enter a value for ${param.Name}`} + helperText={param.Description} validators={fieldRequired(param)} required={param.Required} /> diff --git a/src/components/CippIntegrations/CippApiClientManagement.jsx b/src/components/CippIntegrations/CippApiClientManagement.jsx index 65914ea19dd0..13cb698b8c56 100644 --- a/src/components/CippIntegrations/CippApiClientManagement.jsx +++ b/src/components/CippIntegrations/CippApiClientManagement.jsx @@ -306,6 +306,7 @@ const CippApiClientManagement = () => { name: "AppName", label: "App Name", placeholder: "Enter a name for this Application Registration.", + disableVariables: true, }, { type: "autoComplete", diff --git a/src/components/CippSettings/CippCustomRoles.jsx b/src/components/CippSettings/CippCustomRoles.jsx index aae96ba842bc..56a7ddf651ea 100644 --- a/src/components/CippSettings/CippCustomRoles.jsx +++ b/src/components/CippSettings/CippCustomRoles.jsx @@ -180,13 +180,13 @@ export const CippCustomRoles = () => { const ApiPermissionRow = ({ obj, cat }) => { const [offcanvasVisible, setOffcanvasVisible] = useState(false); + const [descriptionOffcanvasVisible, setDescriptionOffcanvasVisible] = useState(false); + const [selectedDescription, setSelectedDescription] = useState({ name: '', description: '' }); - var items = []; - for (var key in apiPermissions[cat][obj]) - for (var key2 in apiPermissions[cat][obj][key]) { - items.push({ heading: "", content: apiPermissions[cat][obj][key][key2] }); - } - var group = [{ items: items }]; + const handleDescriptionClick = (name, description) => { + setSelectedDescription({ name, description }); + setDescriptionOffcanvasVisible(true); + }; return ( { formControl={formControl} /> + {/* Main offcanvas */} { - setOffcanvasVisible(false); - }} + onClose={() => setOffcanvasVisible(false)} + title={`${cat}.${obj} Endpoints`} > - - {`${cat}.${obj}`} - - Listed below are the available API endpoints based on permission level, ReadWrite - level includes endpoints under Read. + Listed below are the available API endpoints based on permission level. + ReadWrite level includes endpoints under Read. - {[apiPermissions[cat][obj]].map((permissions, key) => { - var sections = Object.keys(permissions).map((type) => { - var items = []; - for (var api in permissions[type]) { - items.push({ heading: "", content: permissions[type][api] }); - } - return ( - - {type} - - {items.map((item, idx) => ( - - {item.content} - - ))} - + {Object.keys(apiPermissions[cat][obj]).map((type, typeIndex) => { + var items = []; + for (var api in apiPermissions[cat][obj][type]) { + const apiFunction = apiPermissions[cat][obj][type][api]; + items.push({ + name: apiFunction.Name, + description: apiFunction.Description?.[0]?.Text || null + }); + } + return ( + + {type} + + {items.map((item, idx) => ( + + + {item.name} + + {item.description && ( + handleDescriptionClick(item.name, item.description)} + sx={{ minWidth: 'auto', p: 0.5 }} + > + + + + + )} + + ))} - ); - }); - return sections; + + ); })} + + {/* Description offcanvas */} + setDescriptionOffcanvasVisible(false)} + title="Function Description" + > + + + {selectedDescription.name} + + + {selectedDescription.description} + + + ); }; diff --git a/src/components/CippSettings/CippRoleAddEdit.jsx b/src/components/CippSettings/CippRoleAddEdit.jsx index abc605f34df7..002d847426be 100644 --- a/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/src/components/CippSettings/CippRoleAddEdit.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Box, @@ -349,13 +349,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const ApiPermissionRow = ({ obj, cat, readOnly }) => { const [offcanvasVisible, setOffcanvasVisible] = useState(false); + const [descriptionOffcanvasVisible, setDescriptionOffcanvasVisible] = useState(false); + const [selectedDescription, setSelectedDescription] = useState({ name: "", description: "" }); - var items = []; - for (var key in apiPermissions[cat][obj]) - for (var key2 in apiPermissions[cat][obj][key]) { - items.push({ heading: "", content: apiPermissions[cat][obj][key][key2] }); - } - var group = [{ items: items }]; + const handleDescriptionClick = (name, description) => { + setSelectedDescription({ name, description }); + setDescriptionOffcanvasVisible(true); + }; return ( { disabled={readOnly} /> + {/* Main offcanvas */} { - setOffcanvasVisible(false); - }} + onClose={() => setOffcanvasVisible(false)} + title={`${cat}.${obj} Endpoints`} > - - {`${cat}.${obj}`} - - Listed below are the available API endpoints based on permission level, ReadWrite + Listed below are the available API endpoints based on permission level. ReadWrite level includes endpoints under Read. - {[apiPermissions[cat][obj]].map((permissions, key) => { - var sections = Object.keys(permissions).map((type) => { - var items = []; - for (var api in permissions[type]) { - items.push({ heading: "", content: permissions[type][api] }); - } - return ( - - {type} - - {items.map((item, idx) => ( - - {item.content} - - ))} - + {Object.keys(apiPermissions[cat][obj]).map((type, typeIndex) => { + var items = []; + for (var api in apiPermissions[cat][obj][type]) { + const apiFunction = apiPermissions[cat][obj][type][api]; + items.push({ + name: apiFunction.Name, + description: apiFunction.Description?.[0]?.Text || null, + }); + } + return ( + + {type} + + {items.map((item, idx) => ( + + + {item.name} + + {item.description && ( + handleDescriptionClick(item.name, item.description)} + sx={{ minWidth: "auto", p: 0.5 }} + > + + + + + )} + + ))} - ); - }); - return sections; + + ); })} + + {/* Description offcanvas */} + setDescriptionOffcanvasVisible(false)} + title="Function Description" + > + + + {selectedDescription.name} + + {selectedDescription.description} + + ); }; @@ -438,6 +463,9 @@ export const CippRoleAddEdit = ({ selectedRole }) => { + + Role Options + {!selectedRole && ( { sortOptions={true} multiple={false} creatable={false} + helperText="Assigning an Entra group will automatically assign this role to all users in that group. This does not work with users invited directly to Static Web App." /> {!isBaseRole && ( @@ -494,6 +523,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { name="allowedTenants" fullWidth={true} includeGroups={true} + helperText="Select the tenants that users should have access to with this role." /> {allTenantSelected && blockedTenants?.length == 0 && ( @@ -512,11 +542,11 @@ export const CippRoleAddEdit = ({ selectedRole }) => { name="blockedTenants" fullWidth={true} includeGroups={true} + helperText="Select tenants that this role should not have access to." /> )} - {/* Blocked Endpoints */} { Object.keys(apiPermissions[cat][obj]).forEach((type) => { Object.keys(apiPermissions[cat][obj][type]).forEach( (apiKey) => { + const apiFunction = apiPermissions[cat][obj][type][apiKey]; + const descriptionText = apiFunction.Description?.[0]?.Text; allEndpoints.push({ - label: apiPermissions[cat][obj][type][apiKey], - value: apiPermissions[cat][obj][type][apiKey], - category: cat, + label: descriptionText + ? `${apiFunction.Name} - ${descriptionText}` + : apiFunction.Name, + value: apiFunction.Name, + category: `${cat}.${obj}.${type}`, }); } ); @@ -567,14 +601,119 @@ export const CippRoleAddEdit = ({ selectedRole }) => { {params.children} )} + helperText="Select specific API endpoints to block for this role, this overrides permission settings below." /> > )} - {apiPermissionFetching && } + {apiPermissionFetching && ( + <> + + + + + + + + + + {[...Array(5)].map((_, index) => ( + + + + + + ))} + > + )} {apiPermissionSuccess && ( <> - API Permissions + {/* Display include/exclude patterns for base roles */} + {isBaseRole && selectedRole && cippRoles[selectedRole]?.include && ( + <> + + Defined Permissions + + + + Include Patterns: + + + These patterns define which permissions are included for this base role: + + + {cippRoles[selectedRole].include.map((pattern, idx) => ( + + {pattern} + + ))} + + + {cippRoles[selectedRole]?.exclude && + cippRoles[selectedRole].exclude.length > 0 && ( + <> + + Exclude Patterns: + + + These patterns define which permissions are explicitly excluded from + this base role: + + + {cippRoles[selectedRole].exclude.map((pattern, idx) => ( + + {pattern} + + ))} + + > + )} + + > + )} + + + API Permissions + {!isBaseRole && ( { alignItems="center" justifyContent={"space-between"} width={"100%"} - sx={{ my: 2 }} + sx={{ mb: 2 }} > Set All Permissions @@ -646,7 +785,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { Allowed Tenants {selectedTenant.map((tenant, idx) => ( - {tenant?.label} + {tenant?.label} ))} > @@ -656,7 +795,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { Blocked Tenants {blockedTenants.map((tenant, idx) => ( - {tenant?.label} + {tenant?.label} ))} > @@ -666,7 +805,10 @@ export const CippRoleAddEdit = ({ selectedRole }) => { Blocked Endpoints {blockedEndpoints.map((endpoint, idx) => ( - + {endpoint?.label || endpoint?.value || endpoint} ))} @@ -681,13 +823,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { Object.keys(selectedPermissions) ?.sort() .map((cat, idx) => ( - <> + {selectedPermissions?.[cat] && typeof selectedPermissions[cat] === "string" && !selectedPermissions[cat]?.includes("None") && ( - {selectedPermissions[cat]} + {selectedPermissions[cat]} )} - > + ))} > diff --git a/src/components/CippSettings/CippRoles.jsx b/src/components/CippSettings/CippRoles.jsx index 15766897d4f4..c155064b634a 100644 --- a/src/components/CippSettings/CippRoles.jsx +++ b/src/components/CippSettings/CippRoles.jsx @@ -1,7 +1,7 @@ import React from "react"; import { Box, Button, SvgIcon } from "@mui/material"; import { CippDataTable } from "../CippTable/CippDataTable"; -import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { PencilIcon, TrashIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline"; import NextLink from "next/link"; import { CippPropertyListCard } from "../../components/CippCards/CippPropertyListCard"; import { getCippTranslation } from "../../utils/get-cipp-translation"; @@ -20,6 +20,34 @@ const CippRoles = () => { ), link: "/cipp/super-admin/cipp-roles/edit?role=[RoleName]", }, + { + label: "Clone", + icon: ( + + + + ), + type: "POST", + url: "/api/ExecCustomRole", + data: { + Action: "Clone", + RoleName: "RoleName", + }, + fields: [ + { + label: "New Role Name", + name: "NewRoleName", + type: "textField", + required: true, + helperText: + "Enter a name for the new cloned role. This cannot be the same as an existing role.", + disableVariables: true, + }, + ], + relatedQueryKeys: ["customRoleList"], + confirmText: "Are you sure you want to clone this custom role?", + condition: (row) => row?.Type === "Custom", + }, { label: "Delete", icon: ( diff --git a/src/components/CippTable/CIPPTableToptoolbar.js b/src/components/CippTable/CIPPTableToptoolbar.js index 9abffd703fba..f4374fbf64b5 100644 --- a/src/components/CippTable/CIPPTableToptoolbar.js +++ b/src/components/CippTable/CIPPTableToptoolbar.js @@ -14,6 +14,10 @@ import { Paper, Checkbox, SvgIcon, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from "@mui/material"; import { Search as SearchIcon, @@ -158,6 +162,7 @@ export const CIPPTableToptoolbar = ({ setGraphFilterData, setConfiguredSimpleColumns, queueMetadata, + isInDialog = false, }) => { const popover = usePopover(); const [filtersAnchor, setFiltersAnchor] = useState(null); @@ -172,6 +177,7 @@ export const CIPPTableToptoolbar = ({ const createDialog = useDialog(); const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false }); const [offcanvasVisible, setOffcanvasVisible] = useState(false); + const [jsonDialogOpen, setJsonDialogOpen] = useState(false); // For dialog-based JSON view const [filterList, setFilterList] = useState(filters); const [currentEffectiveQueryKey, setCurrentEffectiveQueryKey] = useState(queryKey || title); const [originalSimpleColumns, setOriginalSimpleColumns] = useState(simpleColumns); @@ -247,8 +253,6 @@ export const CIPPTableToptoolbar = ({ ) { const last = settings.lastUsedFilters[pageName]; if (last.type === "graph") { - console.log("Early restoring graph filter:", last, "for page:", pageName); - // Mark as restored to prevent infinite loops restoredFiltersRef.current.add(restorationKey); @@ -278,6 +282,21 @@ export const CIPPTableToptoolbar = ({ }); setCurrentEffectiveQueryKey(newQueryKey); setActiveFilterName(last.name); + + if (last.value?.$select) { + let selectColumns = []; + if (Array.isArray(last.value.$select)) { + selectColumns = last.value.$select; + } else if (typeof last.value.$select === "string") { + selectColumns = last.value.$select + .split(",") + .map((col) => col.trim()) + .filter((col) => usedColumns.includes(col)); + } + if (selectColumns.length > 0) { + setConfiguredSimpleColumns(selectColumns); + } + } } } }, [settings.persistFilters, settings.lastUsedFilters, pageName, api?.url, queryKey, title]); @@ -301,7 +320,6 @@ export const CIPPTableToptoolbar = ({ // Use setTimeout to ensure the table is fully rendered const timeoutId = setTimeout(() => { const last = settings.lastUsedFilters[pageName]; - console.log("Restoring filter:", last, "for page:", pageName); if (last.type === "global") { table.setGlobalFilter(last.value); @@ -311,7 +329,6 @@ export const CIPPTableToptoolbar = ({ const allColumns = table.getAllColumns().map((col) => col.id); const filterColumns = Array.isArray(last.value) ? last.value.map((f) => f.id) : []; const allExist = filterColumns.every((colId) => allColumns.includes(colId)); - console.log("Column filter check:", { allColumns, filterColumns, allExist }); if (allExist) { table.setShowColumnFilters(true); table.setColumnFilters(last.value); @@ -417,6 +434,22 @@ export const CIPPTableToptoolbar = ({ return merged; }; + // Shared function for setting nested column visibility + const setNestedVisibility = (col) => { + if (typeof col === "object" && col !== null) { + Object.keys(col).forEach((key) => { + if (usedColumns.includes(key.trim())) { + setColumnVisibility((prev) => ({ ...prev, [key.trim()]: true })); + setNestedVisibility(col[key]); + } + }); + } else { + if (usedColumns.includes(col.trim())) { + setColumnVisibility((prev) => ({ ...prev, [col.trim()]: true })); + } + } + }; + const setTableFilter = (filter, filterType, filterName) => { if (filterType === "global" || filterType === undefined) { table.setGlobalFilter(filter); @@ -478,23 +511,9 @@ export const CIPPTableToptoolbar = ({ let selectedColumns = []; if (Array.isArray(filter?.$select)) { selectedColumns = filter?.$select; - } else { - selectedColumns = filter?.$select.split(","); + } else if (typeof filter?.$select === "string") { + selectedColumns = filter.$select.split(","); } - const setNestedVisibility = (col) => { - if (typeof col === "object" && col !== null) { - Object.keys(col).forEach((key) => { - if (usedColumns.includes(key.trim())) { - setColumnVisibility((prev) => ({ ...prev, [key.trim()]: true })); - setNestedVisibility(col[key]); - } - }); - } else { - if (usedColumns.includes(col.trim())) { - setColumnVisibility((prev) => ({ ...prev, [col.trim()]: true })); - } - } - }; if (selectedColumns.length > 0) { setConfiguredSimpleColumns(selectedColumns); selectedColumns.forEach((col) => { @@ -743,7 +762,7 @@ export const CIPPTableToptoolbar = ({ }) } > - + ))} @@ -922,7 +941,7 @@ export const CIPPTableToptoolbar = ({ }) } > - + ))} @@ -970,7 +989,11 @@ export const CIPPTableToptoolbar = ({ { - setOffcanvasVisible(true); + if (isInDialog) { + setJsonDialogOpen(true); + } else { + setOffcanvasVisible(true); + } setExportAnchor(null); }} > @@ -1139,25 +1162,27 @@ export const CIPPTableToptoolbar = ({ ))} - {/* API Response Off-Canvas */} - { - setOffcanvasVisible(false); - }} - > - - API Response - - - + {/* API Response Off-Canvas - only show when not in dialog mode */} + {!isInDialog && ( + { + setOffcanvasVisible(false); + }} + > + + API Response + + + + )} {/* Action Dialog */} {actionData.ready && ( @@ -1187,23 +1212,9 @@ export const CIPPTableToptoolbar = ({ let selectedColumns = []; if (Array.isArray(filter?.$select)) { selectedColumns = filter?.$select; - } else { - selectedColumns = filter?.$select.split(","); + } else if (typeof filter?.$select === "string") { + selectedColumns = filter.$select.split(","); } - const setNestedVisibility = (col) => { - if (typeof col === "object" && col !== null) { - Object.keys(col).forEach((key) => { - if (usedColumns.includes(key.trim())) { - setColumnVisibility((prev) => ({ ...prev, [key.trim()]: true })); - setNestedVisibility(col[key]); - } - }); - } else { - if (usedColumns.includes(col.trim())) { - setColumnVisibility((prev) => ({ ...prev, [col.trim()]: true })); - } - } - }; if (selectedColumns.length > 0) { setConfiguredSimpleColumns(selectedColumns); selectedColumns.forEach((col) => { @@ -1218,6 +1229,30 @@ export const CIPPTableToptoolbar = ({ component="card" /> + + {/* JSON Dialog for when in dialog mode */} + {isInDialog && ( + setJsonDialogOpen(false)} + sx={{ zIndex: (theme) => theme.zIndex.modal + 1 }} + > + API Response + + + + + setJsonDialogOpen(false)}>Close + + + )} > ); }; diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js index 51bfdca74610..2cfba0bc9640 100644 --- a/src/components/CippTable/CippDataTable.js +++ b/src/components/CippTable/CippDataTable.js @@ -56,6 +56,7 @@ export const CippDataTable = (props) => { filters, maxHeightOffset = "380px", defaultSorting = [], + isInDialog = false, } = props; const [columnVisibility, setColumnVisibility] = useState(initialColumnVisibility); const [configuredSimpleColumns, setConfiguredSimpleColumns] = useState(simpleColumns); @@ -439,6 +440,7 @@ export const CippDataTable = (props) => { setGraphFilterData={setGraphFilterData} setConfiguredSimpleColumns={setConfiguredSimpleColumns} queueMetadata={getRequestData.data?.pages?.[0]?.Metadata} + isInDialog={isInDialog} /> )} > @@ -459,6 +461,37 @@ export const CippDataTable = (props) => { } return aVal > bVal ? 1 : -1; }, + number: (a, b, id) => { + const aVal = a?.original?.[id] ?? null; + const bVal = b?.original?.[id] ?? null; + if (aVal === null && bVal === null) { + return 0; + } + if (aVal === null) { + return 1; + } + if (bVal === null) { + return -1; + } + return aVal - bVal; + }, + boolean: (a, b, id) => { + const aVal = a?.original?.[id] ?? null; + const bVal = b?.original?.[id] ?? null; + if (aVal === null && bVal === null) { + return 0; + } + if (aVal === null) { + return 1; + } + if (bVal === null) { + return -1; + } + // Convert to numbers: true = 1, false = 0 + const aNum = aVal === true ? 1 : 0; + const bNum = bVal === true ? 1 : 0; + return aNum - bNum; + }, }, filterFns: { notContains: (row, columnId, value) => { diff --git a/src/components/CippTable/CippDataTableButton.jsx b/src/components/CippTable/CippDataTableButton.jsx index b2a89b7fc927..79eec0f04bc5 100644 --- a/src/components/CippTable/CippDataTableButton.jsx +++ b/src/components/CippTable/CippDataTableButton.jsx @@ -58,6 +58,7 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { title={tableTitle} data={dialogData} simple={false} + isInDialog={true} /> diff --git a/src/components/CippTable/CippGraphExplorerFilter.js b/src/components/CippTable/CippGraphExplorerFilter.js index 5ce4ca6f1b16..ad1315667b35 100644 --- a/src/components/CippTable/CippGraphExplorerFilter.js +++ b/src/components/CippTable/CippGraphExplorerFilter.js @@ -163,7 +163,7 @@ const CippGraphExplorerFilter = ({ }, [currentEndpoint, debouncedRefetch]); const savePresetApi = ApiPostCall({ - relatedQueryKeys: ["ListGraphExplorerPresets", "ListGraphRequest", ...relatedQueryKeys], + relatedQueryKeys: ["ListGraphExplorerPresets*", "ListGraphRequest", ...relatedQueryKeys], }); // Save preset function @@ -398,6 +398,9 @@ const CippGraphExplorerFilter = ({ Import / Export Graph Explorer Preset + + Copy the JSON below to export your preset, or paste a preset JSON to import it. + setEditorValues(JSON.parse(value))} @@ -412,6 +415,7 @@ const CippGraphExplorerFilter = ({ }} variant="contained" color="primary" + sx={{ mt: 2 }} > Import Template diff --git a/src/components/CippWizard/CippTenantTable.jsx b/src/components/CippWizard/CippTenantTable.jsx index 10d7490babdc..81c1ae4cde73 100644 --- a/src/components/CippWizard/CippTenantTable.jsx +++ b/src/components/CippWizard/CippTenantTable.jsx @@ -149,6 +149,7 @@ export const CippTenantTable = ({ type: "textField", name: "tenantFilter", label: "Default Domain Name or Tenant ID", + disableVariables: true, }, ]} api={{ diff --git a/src/components/CippWizard/CippWizardAssignmentFilterTemplates.jsx b/src/components/CippWizard/CippWizardAssignmentFilterTemplates.jsx new file mode 100644 index 000000000000..7a39a12ce1ee --- /dev/null +++ b/src/components/CippWizard/CippWizardAssignmentFilterTemplates.jsx @@ -0,0 +1,132 @@ +import { Stack } from "@mui/material"; +import CippWizardStepButtons from "./CippWizardStepButtons"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { Grid } from "@mui/system"; +import { useWatch } from "react-hook-form"; +import { useEffect } from "react"; + +export const CippWizardAssignmentFilterTemplates = (props) => { + const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props; + const watcher = useWatch({ control: formControl.control, name: "TemplateList" }); + + const platformOptions = [ + { label: "Windows 10 and Later", value: "windows10AndLater" }, + { label: "iOS", value: "iOS" }, + { label: "Android", value: "android" }, + { label: "macOS", value: "macOS" }, + { label: "Android Work Profile", value: "androidWorkProfile" }, + { label: "Android AOSP", value: "androidAOSP" }, + ]; + + const filterTypeOptions = [ + { label: "Devices", value: "devices" }, + { label: "Apps", value: "apps" }, + ]; + + useEffect(() => { + if (watcher?.value) { + console.log("Loading template:", watcher); + + // Set platform first to ensure conditional fields are visible + formControl.setValue("platform", watcher.addedFields.platform); + + // Use setTimeout to ensure the DOM updates before setting other fields + setTimeout(() => { + formControl.setValue("displayName", watcher.addedFields.displayName); + formControl.setValue("description", watcher.addedFields.description); + formControl.setValue("rule", watcher.addedFields.rule); + formControl.setValue("assignmentFilterManagementType", watcher.addedFields.assignmentFilterManagementType); + + console.log("Set rule to:", watcher.addedFields.rule); + }, 100); + } + }, [watcher]); + + return ( + + + + + `${option.Displayname || option.displayName} (${option.platform})`, + valueField: "GUID", + addedField: { + platform: "platform", + displayName: "displayName", + description: "description", + rule: "rule", + assignmentFilterManagementType: "assignmentFilterManagementType", + }, + showRefresh: true, + }} + /> + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/components/CippWizard/CippWizardOffboarding.jsx b/src/components/CippWizard/CippWizardOffboarding.jsx index 790e8443427a..80b1dd8855e5 100644 --- a/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/src/components/CippWizard/CippWizardOffboarding.jsx @@ -164,6 +164,12 @@ export const CippWizardOffboarding = (props) => { type="switch" formControl={formControl} /> + 180 ? 1 : 0; @@ -1213,8 +1221,8 @@ const ExecutiveReportDocument = ({ `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEndX} ${outerEndY}`, `L ${innerEndX} ${innerEndY}`, `A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStartX} ${innerStartY}`, - 'Z' - ].join(' '); + "Z", + ].join(" "); currentAngle += angle; @@ -1256,15 +1264,17 @@ const ExecutiveReportDocument = ({ .map((value, index) => ({ value, index, - label: chartLabels[index].replace(" Deviations", "").replace(" Policies", ""), - color: chartColors[index] + label: chartLabels[index] + .replace(" Deviations", "") + .replace(" Policies", ""), + color: chartColors[index], })) - .filter(item => item.value > 0); - + .filter((item) => item.value > 0); + return visibleItems.map((item, displayIndex) => { const legendX = 30 + displayIndex * 90; const legendY = 175; - + return ( { data: { tenantFilter: settings.currentTenant, }, + dataKey: "Results", queryKey: `ca-policies-report-${settings.currentTenant}`, waiting: previewOpen, }); diff --git a/src/components/property-list-item.js b/src/components/property-list-item.js index aa61fa0b5d23..4249e975cef0 100644 --- a/src/components/property-list-item.js +++ b/src/components/property-list-item.js @@ -1,16 +1,6 @@ -import { - Box, - Button, - IconButton, - ListItem, - ListItemText, - SvgIcon, - Tooltip, - Typography, -} from "@mui/material"; +import { Box, Button, ListItem, ListItemText, Typography } from "@mui/material"; import { useState } from "react"; -import CopyToClipboard from "react-copy-to-clipboard"; -import { CopyAll } from "@mui/icons-material"; +import { CippCopyToClipBoard } from "./CippComponents/CippCopyToClipboard"; export const PropertyListItem = (props) => { const { @@ -57,17 +47,7 @@ export const PropertyListItem = (props) => { )} > )} - {copyItems && ( - - - - - - - - - - )} + {copyItems && } )} diff --git a/src/data/alerts.json b/src/data/alerts.json index bb336d180e10..7ca2114492e7 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -234,5 +234,11 @@ "inputName": "InputValue", "recommendedRunInterval": "1d", "description": "Monitors tenant alignment scores against standards templates and alerts when the alignment percentage falls below the specified threshold. This helps ensure compliance across all managed tenants." + }, + { + "name": "RestrictedUsers", + "label": "Alert on users restricted from sending email", + "recommendedRunInterval": "30m", + "description": "Monitors for users who have been restricted from sending email due to exceeding outbound spam limits. These users typically indicate a compromised account that needs immediate attention." } ] diff --git a/src/data/standards.json b/src/data/standards.json index 785cdcb8ef4c..d146bf5b9a7a 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -965,10 +965,18 @@ "name": "standards.DisableGuests", "cat": "Entra (AAD) Standards", "tag": [], - "helpText": "Blocks login for guest users that have not logged in for 90 days", - "executiveText": "Automatically disables external guest accounts that haven't been used for 90 days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", - "addedComponent": [], - "label": "Disable Guest accounts that have not logged on for 90 days", + "helpText": "Blocks login for guest users that have not logged in for a number of days", + "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", + "addedComponent": [ + { + "type": "number", + "name": "standards.DisableGuests.days", + "required": true, + "defaultValue": 90, + "label": "Days of inactivity" + } + ], + "label": "Disable Guest accounts that have not logged on for a number of days", "impact": "Medium Impact", "impactColour": "warning", "addedDate": "2022-10-20", @@ -2283,6 +2291,13 @@ ], "helpText": "This creates a Safe Links policy that automatically scans, tracks, and and enables safe links for Email, Office, and Teams for both external and internal senders", "addedComponent": [ + { + "type": "textField", + "name": "standards.SafeLinksPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default SafeLinks Policy" + }, { "type": "switch", "label": "AllowClickThrough", @@ -2330,6 +2345,13 @@ ], "helpText": "This creates a Anti-Phishing policy that automatically enables Mailbox Intelligence and spoofing, optional switches for Mail tips.", "addedComponent": [ + { + "type": "textField", + "name": "standards.AntiPhishPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default Anti-Phishing Policy" + }, { "type": "number", "label": "Phishing email threshold. (Default 1)", @@ -2540,6 +2562,13 @@ ], "helpText": "This creates a Safe Attachment policy", "addedComponent": [ + { + "type": "textField", + "name": "standards.SafeAttachmentPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default Safe Attachment Policy" + }, { "type": "select", "multiple": false, @@ -2684,6 +2713,13 @@ ], "helpText": "This creates a Malware filter policy that enables the default File filter and Zero-hour auto purge for malware.", "addedComponent": [ + { + "type": "textField", + "name": "standards.MalwareFilterPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default Malware Policy" + }, { "type": "select", "multiple": false, @@ -2805,6 +2841,13 @@ "helpText": "This standard creates a Spam filter policy similar to the default strict policy.", "docsDescription": "This standard creates a Spam filter policy similar to the default strict policy, the following settings are configured to on by default: IncreaseScoreWithNumericIps, IncreaseScoreWithRedirectToOtherPort, MarkAsSpamEmptyMessages, MarkAsSpamJavaScriptInHtml, MarkAsSpamSpfRecordHardFail, MarkAsSpamFromAddressAuthFail, MarkAsSpamNdrBackscatter, MarkAsSpamBulkMail, InlineSafetyTipsEnabled, PhishZapEnabled, SpamZapEnabled", "addedComponent": [ + { + "type": "textField", + "name": "standards.SpamFilterPolicy.name", + "label": "Policy Name", + "required": true, + "defaultValue": "CIPP Default Spam Filter Policy" + }, { "type": "number", "label": "Bulk email threshold (Default 7)", @@ -3430,6 +3473,163 @@ "powershellEquivalent": "Graph API", "recommendedBy": [] }, + { + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration", + "cat": "Intune Standards", + "tag": [], + "helpText": "Sets the Windows Hello for Business configuration during device enrollment.", + "executiveText": "Enables or disables Windows Hello for Business during device enrollment, enhancing security through biometric or PIN-based authentication methods. This ensures that devices meet corporate security standards while providing a user-friendly sign-in experience.", + "addedComponent": [ + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.state", + "label": "Configure Windows Hello for Business", + "multiple": false, + "options": [ + { + "label": "Not configured", + "value": "notConfigured" + }, + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + }, + { + "type": "switch", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.securityDeviceRequired", + "label": "Use a Trusted Platform Module (TPM)", + "default": true + }, + { + "type": "number", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinMinimumLength", + "label": "Minimum PIN length (4-127)", + "default": 4 + }, + { + "type": "number", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinMaximumLength", + "label": "Maximum PIN length (4-127)", + "default": 127 + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinLowercaseCharactersUsage", + "label": "Lowercase letters in PIN", + "multiple": false, + "options": [ + { + "label": "Not allowed", + "value": "disallowed" + }, + { + "label": "Allowed", + "value": "allowed" + }, + { + "label": "Required", + "value": "required" + } + ] + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinUppercaseCharactersUsage", + "label": "Uppercase letters in PIN", + "multiple": false, + "options": [ + { + "label": "Not allowed", + "value": "disallowed" + }, + { + "label": "Allowed", + "value": "allowed" + }, + { + "label": "Required", + "value": "required" + } + ] + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinSpecialCharactersUsage", + "label": "Special characters in PIN", + "multiple": false, + "options": [ + { + "label": "Not allowed", + "value": "disallowed" + }, + { + "label": "Allowed", + "value": "allowed" + }, + { + "label": "Required", + "value": "required" + } + ] + }, + { + "type": "number", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinExpirationInDays", + "label": "PIN expiration (days) - 0 to disable", + "default": 0 + }, + { + "type": "number", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.pinPreviousBlockCount", + "label": "PIN history - 0 to disable", + "default": 0 + }, + { + "type": "switch", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.unlockWithBiometricsEnabled", + "label": "Allow biometric authentication", + "default": true + }, + { + "type": "autoComplete", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.enhancedBiometricsState", + "label": "Use enhanced anti-spoofing when available", + "multiple": false, + "options": [ + { + "label": "Not configured", + "value": "notConfigured" + }, + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + }, + { + "type": "switch", + "name": "standards.EnrollmentWindowsHelloForBusinessConfiguration.remotePassportEnabled", + "label": "Allow phone sign-in", + "default": true + } + ], + "label": "Windows Hello for Business enrollment configuration", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-09-25", + "powershellEquivalent": "Graph API", + "recommendedBy": [] + }, { "name": "standards.intuneDeviceReg", "cat": "Intune Standards", @@ -4056,6 +4256,34 @@ "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers EveryoneInCompanyExcludingGuests -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false", "recommendedBy": ["CIS"] }, + { + "name": "standards.TeamsChatProtection", + "cat": "Teams Standards", + "tag": [], + "helpText": "Configures Teams chat protection settings including weaponizable file protection and malicious URL protection.", + "docsDescription": "Configures Teams messaging safety features to protect users from weaponizable files and malicious URLs in chats and channels. Weaponizable File Protection automatically blocks messages containing potentially dangerous file types (like .exe, .dll, .bat, etc.). Malicious URL Protection scans URLs in messages and displays warnings when potentially harmful links are detected. These protections work across internal and external collaboration scenarios.", + "executiveText": "Enables automated security protections in Microsoft Teams to block dangerous files and warn users about malicious links in chat messages. This helps protect employees from file-based attacks and phishing attempts. These safeguards work seamlessly in the background, providing essential protection without disrupting normal business communication.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.TeamsChatProtection.FileTypeCheck", + "label": "Enable Weaponizable File Protection", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.TeamsChatProtection.UrlReputationCheck", + "label": "Enable Malicious URL Protection", + "defaultValue": true + } + ], + "label": "Set Teams Chat Protection Settings", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-10-02", + "powershellEquivalent": "Set-CsTeamsMessagingConfiguration -FileTypeCheck 'Enabled' -UrlReputationCheck 'Enabled' -ReportIncorrectSecurityDetections 'Enabled'", + "recommendedBy": ["CIPP"] + }, { "name": "standards.TeamsEmailIntegration", "cat": "Teams Standards", @@ -4652,7 +4880,31 @@ "label": "Exclude Groups", "type": "textField", "required": false, - "helpText": "Enter the group name to exclude from the assignment. Wildcards are allowed." + "helpText": "Enter the group name(s) to exclude from the assignment. Wildcards are allowed. Multiple group names are comma-seperated." + }, + { + "type": "textField", + "required": false, + "name": "assignmentFilter", + "label": "Assignment Filter Name (Optional)", + "helpText": "Enter the assignment filter name to apply to this policy assignment. Wildcards are allowed." + }, + { + "name": "assignmentFilterType", + "label": "Assignment Filter Mode (Optional)", + "type": "radio", + "required": false, + "helpText": "Choose whether to include or exclude devices matching the filter. Only applies if you specified a filter name above. Defaults to Include if not specified.", + "options": [ + { + "label": "Include - Assign to devices matching the filter", + "value": "include" + }, + { + "label": "Exclude - Assign to devices NOT matching the filter", + "value": "exclude" + } + ] } ] }, @@ -4796,6 +5048,35 @@ } ] }, + { + "name": "standards.AssignmentFilterTemplate", + "label": "Assignment Filter Template", + "multi": true, + "cat": "Templates", + "disabledFeatures": { + "report": true, + "warn": true, + "remediate": false + }, + "impact": "Medium Impact", + "addedDate": "2025-10-04", + "helpText": "Deploy and manage assignment filter templates.", + "executiveText": "Creates standardized assignment filters with predefined settings. These templates ensure consistent assignment filter configurations across the organization, streamlining assignment management.", + "addedComponent": [ + { + "type": "autoComplete", + "name": "assignmentFilterTemplate", + "label": "Select Assignment Filter Template", + "api": { + "url": "/api/ListAssignmentFilterTemplates", + "labelField": "Displayname", + "altLabelField": "displayName", + "valueField": "GUID", + "queryKey": "ListAssignmentFilterTemplates" + } + } + ] + }, { "name": "standards.MailboxRecipientLimits", "cat": "Exchange Standards", @@ -4876,5 +5157,134 @@ "addedDate": "2025-08-26", "powershellEquivalent": "None", "recommendedBy": ["Microsoft"] + }, + { + "name": "standards.DeployCheckChromeExtension", + "cat": "Intune Standards", + "tag": [], + "helpText": "Deploys the Check Chrome extension via Intune OMA-URI custom policies for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg", + "docsDescription": "Creates Intune OMA-URI custom policies that automatically install and configure the Check Chrome extension on managed devices for both Google Chrome and Microsoft Edge browsers. This ensures the extension is deployed consistently across all corporate devices with customizable settings.", + "executiveText": "Automatically deploys the Check browser extension across all company devices with configurable security and branding settings, ensuring consistent security monitoring and compliance capabilities. This extension provides enhanced security features and monitoring tools that help protect against threats while maintaining user productivity.", + "addedComponent": [ + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enableValidPageBadge", + "label": "Enable valid page badge", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enablePageBlocking", + "label": "Enable page blocking", + "defaultValue": true + }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enableCippReporting", + "label": "Enable CIPP reporting", + "defaultValue": true + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.cippServerUrl", + "label": "CIPP Server URL", + "placeholder": "https://YOUR-CIPP-SERVER-URL", + "required": false + }, + + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.customRulesUrl", + "label": "Custom Rules URL", + "placeholder": "https://YOUR-CIPP-SERVER-URL/rules.json", + "required": false + }, + { + "type": "number", + "name": "standards.DeployCheckChromeExtension.updateInterval", + "label": "Update interval (hours)", + "defaultValue": 12 + }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enableDebugLogging", + "label": "Enable debug logging", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.companyName", + "label": "Company Name", + "placeholder": "YOUR-COMPANY", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.productName", + "label": "Product Name", + "placeholder": "YOUR-PRODUCT-NAME", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.supportEmail", + "label": "Support Email", + "placeholder": "support@yourcompany.com", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.primaryColor", + "label": "Primary Color", + "placeholder": "#0044CC", + "required": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.logoUrl", + "label": "Logo URL", + "placeholder": "https://yourcompany.com/logo.png", + "required": false + }, + { + "name": "AssignTo", + "label": "Who should this policy be assigned to?", + "type": "radio", + "options": [ + { + "label": "Do not assign", + "value": "On" + }, + { + "label": "Assign to all users", + "value": "allLicensedUsers" + }, + { + "label": "Assign to all devices", + "value": "AllDevices" + }, + { + "label": "Assign to all users and devices", + "value": "AllDevicesAndUsers" + }, + { + "label": "Assign to Custom Group", + "value": "customGroup" + } + ] + }, + { + "type": "textField", + "required": false, + "name": "customGroup", + "label": "Enter the custom group name if you selected 'Assign to Custom Group'. Wildcards are allowed." + } + ], + "label": "Deploy Check Chrome Extension", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2025-09-18", + "powershellEquivalent": "New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies'", + "recommendedBy": ["CIPP"] } ] diff --git a/src/layouts/config.js b/src/layouts/config.js index 2add01700bb5..115de1795d46 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -183,18 +183,6 @@ export const nativeMenuItems = [ path: "/tenant/gdap-management/", permissions: ["Tenant.Relationship.*"], }, - { - title: "Configuration Backup", - path: "/tenant/backup", - permissions: ["CIPP.Backup.*"], - items: [ - { - title: "Backups", - path: "/tenant/backup/backup-wizard", - permissions: ["CIPP.Backup.*"], - }, - ], - }, { title: "Standards & Drift", path: "/tenant/standards", @@ -307,6 +295,11 @@ export const nativeMenuItems = [ path: "/security/incidents/list-mdo-alerts", permissions: ["Security.Alert.*"], }, + { + title: "Check Alerts", + path: "/security/incidents/list-check-alerts", + permissions: ["Security.Alert.*"], + }, ], }, { @@ -452,6 +445,16 @@ export const nativeMenuItems = [ path: "/endpoint/MEM/list-templates", permissions: ["Endpoint.MEM.*"], }, + { + title: "Assignment Filters", + path: "/endpoint/MEM/assignment-filters", + permissions: ["Endpoint.MEM.*"], + }, + { + title: "Assignment Filter Templates", + path: "/endpoint/MEM/assignment-filter-templates", + permissions: ["Endpoint.MEM.*"], + }, { title: "Scripts", path: "/endpoint/MEM/list-scripts", @@ -590,6 +593,11 @@ export const nativeMenuItems = [ path: "/email/administration/quarantine", permissions: ["Exchange.SpamFilter.*"], }, + { + title: "Restricted Users", + path: "/email/administration/restricted-users", + permissions: ["Exchange.Mailbox.*"], + }, { title: "Tenant Allow/Block Lists", path: "/email/administration/tenant-allow-block-lists", @@ -718,11 +726,6 @@ export const nativeMenuItems = [ path: "/email/reports/malware-filters", permissions: ["Exchange.SpamFilter.*"], }, - { - title: "Safe Links Filters", - path: "/email/reports/safelinks-filters", - permissions: ["Exchange.SafeLinks.*"], - }, { title: "Safe Attachments Filters", path: "/email/reports/safeattachments-filters", diff --git a/src/pages/cipp/advanced/table-maintenance.js b/src/pages/cipp/advanced/table-maintenance.js index d7bc058697ee..4f4c53c30fb7 100644 --- a/src/pages/cipp/advanced/table-maintenance.js +++ b/src/pages/cipp/advanced/table-maintenance.js @@ -72,6 +72,7 @@ const CustomAddEditRowDialog = ({ formControl, open, onClose, onSubmit, defaultV name={`fields[${index}].name`} formControl={formControl} label="Name" + disableVariables={true} /> @@ -101,6 +102,7 @@ const CustomAddEditRowDialog = ({ formControl, open, onClose, onSubmit, defaultV return {}; } }} + disableVariables={true} /> diff --git a/src/pages/cipp/custom-data/directory-extensions/add.js b/src/pages/cipp/custom-data/directory-extensions/add.js index 7238792aa4dd..975c7be997de 100644 --- a/src/pages/cipp/custom-data/directory-extensions/add.js +++ b/src/pages/cipp/custom-data/directory-extensions/add.js @@ -62,6 +62,7 @@ const Page = () => { type: "textField", required: true, placeholder: "Enter a unique name for the directory extension", + disableVariables: true, }, { name: "dataType", diff --git a/src/pages/cipp/custom-data/mappings/add.js b/src/pages/cipp/custom-data/mappings/add.js index d672d42d888d..cd77fc9eff6e 100644 --- a/src/pages/cipp/custom-data/mappings/add.js +++ b/src/pages/cipp/custom-data/mappings/add.js @@ -18,15 +18,42 @@ const Page = () => { const addMappingApi = ApiPostCall({ urlFromData: true, - relatedQueryKeys: ["MappingsListPage"], + relatedQueryKeys: ["MappingsListPage", "ManualEntryMappings*"], }); const handleAddMapping = (data) => { + // Filter data based on source type to only include relevant fields + let filteredData; + + if (data.sourceType?.value === "manualEntry") { + // For manual entry, only include these fields + filteredData = { + sourceType: data.sourceType, + manualEntryFieldLabel: data.manualEntryFieldLabel, + directoryObjectType: data.directoryObjectType, + customDataAttribute: data.customDataAttribute, + tenantFilter: data.tenantFilter, + }; + } else if (data.sourceType?.value === "extensionSync") { + // For extension sync, include the original fields + filteredData = { + sourceType: data.sourceType, + extensionSyncDataset: data.extensionSyncDataset, + extensionSyncProperty: data.extensionSyncProperty, + directoryObjectType: data.directoryObjectType, + customDataAttribute: data.customDataAttribute, + tenantFilter: data.tenantFilter, + }; + } else { + // Fallback to all data if source type is not recognized + filteredData = data; + } + addMappingApi.mutate({ url: "/api/ExecCustomData", data: { Action: "AddEditMapping", - Mapping: data, + Mapping: filteredData, }, }); }; diff --git a/src/pages/cipp/custom-data/mappings/edit.js b/src/pages/cipp/custom-data/mappings/edit.js index 10bce76f56f2..55a2bbc648e8 100644 --- a/src/pages/cipp/custom-data/mappings/edit.js +++ b/src/pages/cipp/custom-data/mappings/edit.js @@ -3,13 +3,7 @@ import { useForm, useFormState } from "react-hook-form"; import { useRouter } from "next/router"; import { useEffect } from "react"; import { ApiPostCall, ApiGetCall } from "/src/api/ApiCall"; -import { - Button, - Stack, - CardContent, - CardActions, - Skeleton, -} from "@mui/material"; +import { Button, Stack, CardContent, CardActions, Skeleton } from "@mui/material"; import CippPageCard from "/src/components/CippCards/CippPageCard"; import { CippApiResults } from "/src/components/CippComponents/CippApiResults"; @@ -39,11 +33,39 @@ const Page = () => { }); const handleEditMapping = (data) => { + // Filter data based on source type to only include relevant fields + let filteredData; + + if (data.sourceType?.value === "manualEntry") { + // For manual entry, only include these fields + filteredData = { + sourceType: data.sourceType, + manualEntryFieldLabel: data.manualEntryFieldLabel, + directoryObjectType: data.directoryObjectType, + customDataAttribute: data.customDataAttribute, + tenantFilter: data.tenantFilter, + }; + } else if (data.sourceType?.value === "extensionSync") { + // For extension sync, include the original fields + filteredData = { + sourceType: data.sourceType, + extensionSyncDataset: data.extensionSyncDataset, + extensionSyncProperty: data.extensionSyncProperty, + directoryObjectType: data.directoryObjectType, + customDataAttribute: data.customDataAttribute, + tenantFilter: data.tenantFilter, + }; + } else { + // Fallback to all data if source type is not recognized + filteredData = data; + } + editMappingApi.mutate({ url: "/api/ExecCustomData", data: { Action: "AddEditMapping", - Mapping: { ...data, id }, // Include the ID for editing + id: id, // ID at top level for PowerShell function + Mapping: filteredData, }, }); }; diff --git a/src/pages/cipp/custom-data/schema-extensions/add.js b/src/pages/cipp/custom-data/schema-extensions/add.js index 605d50ed4f15..07be1c409067 100644 --- a/src/pages/cipp/custom-data/schema-extensions/add.js +++ b/src/pages/cipp/custom-data/schema-extensions/add.js @@ -101,6 +101,7 @@ const Page = () => { required: true, placeholder: "Enter a schema id (e.g. cippUser). The prefix is generated automatically after creation.", + disableVariables: true, }, { name: "description", @@ -108,6 +109,7 @@ const Page = () => { type: "textField", required: true, placeholder: "Enter a description for the schema extension", + disableVariables: true, }, { name: "status", diff --git a/src/pages/cipp/custom-data/schema-extensions/index.js b/src/pages/cipp/custom-data/schema-extensions/index.js index f7b5729187ba..daca34251209 100644 --- a/src/pages/cipp/custom-data/schema-extensions/index.js +++ b/src/pages/cipp/custom-data/schema-extensions/index.js @@ -40,6 +40,7 @@ const Page = () => { label: "Property Name", type: "textField", required: true, + disableVariables: true, }, { name: "type", diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index a33e9f1f612a..f59032fbcba5 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -75,11 +75,13 @@ const Page = () => { type: "textField", name: "GUID", label: "GUID", + disableVariables: true, }, { type: "textField", name: "SKUName", label: "SKU Name", + disableVariables: true, }, ]} api={{ diff --git a/src/pages/cipp/settings/notifications.js b/src/pages/cipp/settings/notifications.js index dbb0727e0be4..408bdf6f5169 100644 --- a/src/pages/cipp/settings/notifications.js +++ b/src/pages/cipp/settings/notifications.js @@ -22,13 +22,10 @@ const Page = () => { formControl={formControl} resetForm={false} postUrl="/api/ExecNotificationConfig" - relatedQueryKeys={["ListNotificationConfig"]} + queryKey={"ListNotificationConfig"} > {/* Use the reusable notification form component */} - + ); }; diff --git a/src/pages/email/administration/contacts-template/deploy.jsx b/src/pages/email/administration/contacts-template/deploy.jsx deleted file mode 100644 index 6766b209d6ba..000000000000 --- a/src/pages/email/administration/contacts-template/deploy.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Divider } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import CippFormPage from "/src/components/CippFormPages/CippFormPage"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormTenantSelector } from "/src/components/CippComponents/CippFormTenantSelector"; - -const Page = () => { - const formControl = useForm({ - mode: "onChange", - defaultValues: { - selectedTenants: [], - TemplateList: [], - }, - }); - - return ( - - - - - - - - - {/* TemplateList */} - - option, - url: "/api/ListContactTemplates", - }} - placeholder="Select a template or enter PowerShell JSON manually" - /> - - - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/pages/email/administration/contacts-template/index.jsx b/src/pages/email/administration/contacts-template/index.jsx index 6257f6aa9a5c..718d0c2f8ae5 100644 --- a/src/pages/email/administration/contacts-template/index.jsx +++ b/src/pages/email/administration/contacts-template/index.jsx @@ -6,9 +6,11 @@ import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx" import { TrashIcon } from "@heroicons/react/24/outline"; import { GitHub, Edit } from "@mui/icons-material"; import { ApiGetCall } from "/src/api/ApiCall"; +import { CippDeployContactTemplateDrawer } from "../../../../components/CippComponents/CippDeployContactTemplateDrawer"; const Page = () => { const pageTitle = "Contact Templates"; + const cardButtonPermissions = ["Exchange.Contact.ReadWrite"]; const integrations = ApiGetCall({ url: "/api/ListExtensionsConfig", queryKey: "Integrations", @@ -72,12 +74,12 @@ const Page = () => { color: "danger", }, { - label: "Edit Contact Template", - link: "/email/administration/contacts-template/edit?id=[GUID]", - icon: , - color: "success", - target: "_self", - }, + label: "Edit Contact Template", + link: "/email/administration/contacts-template/edit?id=[GUID]", + icon: , + color: "success", + target: "_self", + }, ]; const simpleColumns = ["name", "contactTemplateName", "GUID"]; @@ -90,13 +92,7 @@ const Page = () => { simpleColumns={simpleColumns} cardButton={ <> - } - > - Deploy Contact Template - + { - const tenantDomain = useSettings().currentTenant; - - const formControl = useForm({ - mode: "onChange", - defaultValues: { - displayName: "", - firstName: "", - lastName: "", - email: "", - hidefromGAL: false, - streetAddress: "", - postalCode: "", - city: "", - state: "", - country: "", - companyName: "", - mobilePhone: "", - businessPhone: "", - jobTitle: "", - website: "", - mailTip: "", - }, - }); - - return ( - { - return { - tenantID: tenantDomain, - DisplayName: values.displayName, - hidefromGAL: values.hidefromGAL, - email: values.email, - FirstName: values.firstName, - LastName: values.lastName, - Title: values.jobTitle, - StreetAddress: values.streetAddress, - PostalCode: values.postalCode, - City: values.city, - State: values.state, - CountryOrRegion: values.country?.value || values.country, - Company: values.companyName, - mobilePhone: values.mobilePhone, - phone: values.businessPhone, - website: values.website, - mailTip: values.mailTip, - }; - }} - > - - {/* Display Name */} - - - - - {/* First Name and Last Name */} - - - - - - - - - - {/* Email */} - - - - - {/* Hide from GAL */} - - - - - - - - ); -}; - -AddContact.getLayout = (page) => {page}; - -export default AddContact; diff --git a/src/pages/email/administration/contacts/index.js b/src/pages/email/administration/contacts/index.js index 48e5f2a65453..c8ab6314827e 100644 --- a/src/pages/email/administration/contacts/index.js +++ b/src/pages/email/administration/contacts/index.js @@ -1,66 +1,59 @@ import { useMemo } from "react"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Edit, PersonAdd } from "@mui/icons-material"; -import { Button } from "@mui/material"; -import Link from "next/link"; +import { Edit } from "@mui/icons-material"; import TrashIcon from "@heroicons/react/24/outline/TrashIcon"; +import { CippAddContactDrawer } from "../../../../components/CippComponents/CippAddContactDrawer"; +import { CippDeployContactTemplateDrawer } from "../../../../components/CippComponents/CippDeployContactTemplateDrawer"; const Page = () => { const pageTitle = "Contacts"; - const actions = useMemo(() => [ - { - label: "Edit Contact", - link: "/email/administration/contacts/edit?id=[Guid]", - multiPost: false, - postEntireRow: true, - icon: , - color: "warning", - condition: (row) => !row.IsDirSynced, - }, - { - label: "Remove Contact", - type: "POST", - url: "/api/RemoveContact", - data: { - GUID: "Guid", - mail: "WindowsEmailAddress", + const cardButtonPermissions = ["Exchange.Contact.ReadWrite"]; + const actions = useMemo( + () => [ + { + label: "Edit Contact", + link: "/email/administration/contacts/edit?id=[Guid]", + multiPost: false, + postEntireRow: true, + icon: , + color: "warning", + condition: (row) => !row.IsDirSynced, }, - confirmText: - "Are you sure you want to delete this contact? Remember this will not work if the contact is AD Synced.", - color: "danger", - icon: , - condition: (row) => !row.IsDirSynced, - }, - ], []); - - const simpleColumns = useMemo(() => [ - "DisplayName", - "WindowsEmailAddress", - "Company", - "IsDirSynced" - ], []); - - const cardButton = useMemo(() => ( - } - > - Add contact - - ), []); + { + label: "Remove Contact", + type: "POST", + url: "/api/RemoveContact", + data: { + GUID: "Guid", + mail: "WindowsEmailAddress", + }, + confirmText: + "Are you sure you want to delete this contact? Remember this will not work if the contact is AD Synced.", + color: "danger", + icon: , + condition: (row) => !row.IsDirSynced, + }, + ], + [] + ); + const simpleColumns = ["DisplayName", "WindowsEmailAddress", "Company", "IsDirSynced"]; return ( + + + > + } /> ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/administration/deleted-mailboxes/index.js b/src/pages/email/administration/deleted-mailboxes/index.js index d276a2ab998f..488c783dd71f 100644 --- a/src/pages/email/administration/deleted-mailboxes/index.js +++ b/src/pages/email/administration/deleted-mailboxes/index.js @@ -15,5 +15,5 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/administration/exchange-retention/policies/index.js b/src/pages/email/administration/exchange-retention/policies/index.js index c465475879cf..ede3ed7034ea 100644 --- a/src/pages/email/administration/exchange-retention/policies/index.js +++ b/src/pages/email/administration/exchange-retention/policies/index.js @@ -13,54 +13,56 @@ const Page = () => { 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, - }; + const actions = useMemo( + () => [ + { + label: "Edit Policy", + link: "/email/administration/exchange-retention/policies/policy?name=[Name]", + multiPost: false, + postEntireRow: true, + icon: , + color: "warning", }, - }, - ], [tenant]); + { + 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 simpleColumns = useMemo( + () => ["Name", "IsDefault", "IsDefaultArbitrationMailbox", "RetentionPolicyTagLinks"], + [] + ); - const cardButton = useMemo(() => ( - } - > - Add Retention Policy - - ), []); + const cardButton = useMemo( + () => ( + } + > + Add Retention Policy + + ), + [] + ); return ( - + { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; -export default Page; \ No newline at end of file +export default Page; diff --git a/src/pages/email/administration/exchange-retention/tags/index.js b/src/pages/email/administration/exchange-retention/tags/index.js index e8299b401eca..8daa866d0c8a 100644 --- a/src/pages/email/administration/exchange-retention/tags/index.js +++ b/src/pages/email/administration/exchange-retention/tags/index.js @@ -13,56 +13,63 @@ 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, - }; + const actions = useMemo( + () => [ + { + label: "Edit Tag", + link: "/email/administration/exchange-retention/tags/tag?name=[Name]", + multiPost: false, + postEntireRow: true, + icon: , + color: "warning", }, - }, - ], [tenant]); + { + 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 simpleColumns = useMemo( + () => [ + "Name", + "Type", + "RetentionAction", + "AgeLimitForRetention", + "RetentionEnabled", + "Comment", + ], + [] + ); - const cardButton = useMemo(() => ( - } - > - Add Retention Tag - - ), []); + const cardButton = useMemo( + () => ( + } + > + Add Retention Tag + + ), + [] + ); return ( - + { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; -export default Page; \ No newline at end of file +export default Page; diff --git a/src/pages/email/administration/mailboxes/index.js b/src/pages/email/administration/mailboxes/index.js index 3687c04b512e..c2abbe1b854e 100644 --- a/src/pages/email/administration/mailboxes/index.js +++ b/src/pages/email/administration/mailboxes/index.js @@ -64,6 +64,6 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/administration/restricted-users/index.js b/src/pages/email/administration/restricted-users/index.js new file mode 100644 index 000000000000..faa3b3792daa --- /dev/null +++ b/src/pages/email/administration/restricted-users/index.js @@ -0,0 +1,96 @@ +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { Alert, Link, Typography, List, ListItem, ListItemText } from "@mui/material"; +import { Block as BlockIcon } from "@mui/icons-material"; + +const Page = () => { + const pageTitle = "Restricted Users"; + + const actions = [ + { + label: "Unblock User", + type: "POST", + icon: , + confirmText: + "Are you sure you want to unblock [SenderAddress]? Unblocking can take up to 1 hour. Make sure you have secured the account before proceeding.", + url: "/api/ExecRemoveRestrictedUser", + data: { SenderAddress: "SenderAddress" }, + color: "success", + }, + ]; + + const simpleColumns = [ + "SenderAddress", + "BlockType", + "CreatedDatetime", + "ChangedDatetime", + "TemporaryBlock", + "InternalCount", + "ExternalCount", + ]; + + return ( + <> + + + + Users in this list have been restricted from sending email due to exceeding outbound + spam limits. + + + + This typically indicates a compromised account.{" "} + + Before unblocking, ensure you have properly secured the account. + + + + Recommended actions include: + + + + + + + + + + + + + + + + + + + + + > + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/email/administration/tenant-allow-block-lists/index.js b/src/pages/email/administration/tenant-allow-block-lists/index.js index 4770163895fa..bf0f3e3275bf 100644 --- a/src/pages/email/administration/tenant-allow-block-lists/index.js +++ b/src/pages/email/administration/tenant-allow-block-lists/index.js @@ -58,5 +58,5 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/reports/SharedMailboxEnabledAccount/index.js b/src/pages/email/reports/SharedMailboxEnabledAccount/index.js index dbd167e86105..4323f53703aa 100644 --- a/src/pages/email/reports/SharedMailboxEnabledAccount/index.js +++ b/src/pages/email/reports/SharedMailboxEnabledAccount/index.js @@ -37,13 +37,13 @@ const Page = () => { filters={[ { id: "accountEnabled", - value: "Yes" - } + value: "Yes", + }, ]} /> ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/reports/antiphishing-filters/index.js b/src/pages/email/reports/antiphishing-filters/index.js index 23cee4f3b5dd..c691d035088b 100644 --- a/src/pages/email/reports/antiphishing-filters/index.js +++ b/src/pages/email/reports/antiphishing-filters/index.js @@ -98,6 +98,6 @@ const Page = () => { }; // Layout configuration: ensure page uses DashboardLayout -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/reports/global-address-list/index.js b/src/pages/email/reports/global-address-list/index.js index 314fb23f66f0..c8b7ff18d0d7 100644 --- a/src/pages/email/reports/global-address-list/index.js +++ b/src/pages/email/reports/global-address-list/index.js @@ -81,6 +81,6 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/reports/mailbox-cas-settings/index.js b/src/pages/email/reports/mailbox-cas-settings/index.js index 9d7eaccd5f57..10cb93963b58 100644 --- a/src/pages/email/reports/mailbox-cas-settings/index.js +++ b/src/pages/email/reports/mailbox-cas-settings/index.js @@ -24,6 +24,6 @@ const Page = () => { // No actions were specified in the original code, so no actions are added here. // No off-canvas configuration was provided or specified in the original code. -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/reports/malware-filters/index.js b/src/pages/email/reports/malware-filters/index.js index 4e0c638e0f8a..20f09161b7d1 100644 --- a/src/pages/email/reports/malware-filters/index.js +++ b/src/pages/email/reports/malware-filters/index.js @@ -89,6 +89,6 @@ const Page = () => { - Additional action for "Delete Rule" is commented for developer convenience, pending further instruction. */ -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/reports/safeattachments-filters/index.js b/src/pages/email/reports/safeattachments-filters/index.js index 3821410fe6e0..a35d329be6ca 100644 --- a/src/pages/email/reports/safeattachments-filters/index.js +++ b/src/pages/email/reports/safeattachments-filters/index.js @@ -88,6 +88,6 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/resources/management/equipment/add.jsx b/src/pages/email/resources/management/equipment/add.jsx deleted file mode 100644 index a186a19e0ba5..000000000000 --- a/src/pages/email/resources/management/equipment/add.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react"; -import { Divider } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import CippFormPage from "/src/components/CippFormPages/CippFormPage"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormDomainSelector } from "/src/components/CippComponents/CippFormDomainSelector"; -import { useSettings } from "/src/hooks/use-settings"; - -const AddEquipmentMailbox = () => { - const tenantDomain = useSettings().currentTenant; - const formControl = useForm({ - mode: "onChange", - defaultValues: { - displayName: "", - username: "", - domain: null, - location: "", - department: "", - company: "", - }, - }); - - return ( - { - const shippedValues = { - tenantID: tenantDomain, - domain: values.domain?.value, - displayName: values.displayName.trim(), - username: values.username.trim(), - userPrincipalName: values.username.trim() + "@" + (values.domain?.value || "").trim(), - }; - - return shippedValues; - }} - > - - {/* Display Name */} - - - - - - - {/* Username and Domain */} - - - - - - - - - ); -}; - -AddEquipmentMailbox.getLayout = (page) => {page}; - -export default AddEquipmentMailbox; diff --git a/src/pages/email/resources/management/equipment/index.js b/src/pages/email/resources/management/equipment/index.js index 9a3f97e29cc5..538143f24b96 100644 --- a/src/pages/email/resources/management/equipment/index.js +++ b/src/pages/email/resources/management/equipment/index.js @@ -1,12 +1,12 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Button } from "@mui/material"; -import Link from "next/link"; -import { AddBusiness, Edit, Block, LockOpen, Key } from "@mui/icons-material"; +import { Edit, Block, LockOpen, Key } from "@mui/icons-material"; import { TrashIcon } from "@heroicons/react/24/outline"; +import { CippAddEquipmentDrawer } from "../../../../../components/CippComponents/CippAddEquipmentDrawer"; const Page = () => { const pageTitle = "Equipment"; + const cardButtonPermissions = ["Exchange.Equipment.ReadWrite"]; const actions = [ { @@ -54,30 +54,24 @@ const Page = () => { }, ]; + const simpleColumns = [ + "DisplayName", + "UserPrincipalName", + "HiddenFromAddressListsEnabled", + "PrimarySmtpAddress", + ]; + return ( } - > - Add Equipment - - } + simpleColumns={simpleColumns} + cardButton={} /> ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/resources/management/list-rooms/add.jsx b/src/pages/email/resources/management/list-rooms/add.jsx deleted file mode 100644 index b4ef65fd7317..000000000000 --- a/src/pages/email/resources/management/list-rooms/add.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from "react"; -import { Divider } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm } from "react-hook-form"; -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import CippFormPage from "/src/components/CippFormPages/CippFormPage"; -import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; -import { CippFormDomainSelector } from "/src/components/CippComponents/CippFormDomainSelector"; -import { useSettings } from "/src/hooks/use-settings"; - -const AddRoomMailbox = () => { - const tenantDomain = useSettings().currentTenant; - const formControl = useForm({ - mode: "onChange", - defaultValues: { - displayName: "", - username: "", - domain: null, - resourceCapacity: "", - }, - }); - - return ( - { - const shippedValues = { - tenantID: tenantDomain, - domain: values.domain?.value, - displayName: values.displayName.trim(), - username: values.username.trim(), - userPrincipalName: values.username.trim() + "@" + (values.domain?.value || "").trim(), - }; - - if (values.resourceCapacity && values.resourceCapacity.trim() !== "") { - shippedValues.resourceCapacity = values.resourceCapacity.trim(); - } - - return shippedValues; - }} - > - - {/* Display Name */} - - - - - - - {/* Username and Domain */} - - - - - - - - - - {/* Resource Capacity (Optional) */} - - - - - - ); -}; - -AddRoomMailbox.getLayout = (page) => {page}; - -export default AddRoomMailbox; diff --git a/src/pages/email/resources/management/list-rooms/index.js b/src/pages/email/resources/management/list-rooms/index.js index 68c79ba360c2..61201421734a 100644 --- a/src/pages/email/resources/management/list-rooms/index.js +++ b/src/pages/email/resources/management/list-rooms/index.js @@ -1,12 +1,12 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Button } from "@mui/material"; -import Link from "next/link"; -import { AddHomeWork, Edit, Block, LockOpen, Key } from "@mui/icons-material"; +import { Edit, Block, LockOpen, Key } from "@mui/icons-material"; import { TrashIcon } from "@heroicons/react/24/outline"; +import { CippAddRoomDrawer } from "../../../../../components/CippComponents/CippAddRoomDrawer"; const Page = () => { const pageTitle = "Rooms"; + const cardButtonPermissions = ["Exchange.Room.ReadWrite"]; const actions = [ { @@ -70,19 +70,11 @@ const Page = () => { "countryOrRegion", "hiddenFromAddressListsEnabled", ]} - cardButton={ - } - > - Add Room - - } + cardButton={} /> ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/resources/management/room-lists/add.jsx b/src/pages/email/resources/management/room-lists/add.jsx deleted file mode 100644 index 606259d728e0..000000000000 --- a/src/pages/email/resources/management/room-lists/add.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Box } from "@mui/material"; -import CippFormPage from "../../../../../components/CippFormPages/CippFormPage"; -import { Layout as DashboardLayout } from "/src/layouts/index.js"; -import { useForm } from "react-hook-form"; -import { useSettings } from "../../../../../hooks/use-settings"; -import CippAddRoomListForm from "../../../../../components/CippFormPages/CippAddRoomListForm"; - -const Page = () => { - const userSettingsDefaults = useSettings(); - const tenantDomain = userSettingsDefaults.currentTenant; - - const formControl = useForm({ - mode: "onChange", - defaultValues: { - displayName: "", - username: "", - primDomain: null, - }, - }); - - return ( - <> - { - return { - tenantFilter: tenantDomain, - displayName: values.displayName?.trim(), - username: values.username?.trim(), - primDomain: values.primDomain, - }; - }} - > - - - - - > - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; \ No newline at end of file diff --git a/src/pages/email/resources/management/room-lists/index.js b/src/pages/email/resources/management/room-lists/index.js index b29198a98e9a..ce8573687897 100644 --- a/src/pages/email/resources/management/room-lists/index.js +++ b/src/pages/email/resources/management/room-lists/index.js @@ -1,13 +1,13 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Visibility, ListAlt, Edit } from "@mui/icons-material"; +import { Edit } from "@mui/icons-material"; import { TrashIcon } from "@heroicons/react/24/outline"; -import { Button } from "@mui/material"; -import Link from "next/link"; +import { CippAddRoomListDrawer } from "../../../../../components/CippComponents/CippAddRoomListDrawer"; const Page = () => { const pageTitle = "Room Lists"; const apiUrl = "/api/ListRoomLists"; + const cardButtonPermissions = ["Exchange.Room.ReadWrite"]; const actions = [ { @@ -45,13 +45,7 @@ const Page = () => { actions: actions, }; - const simpleColumns = [ - "DisplayName", - "PrimarySmtpAddress", - "Identity", - "Phone", - "Notes", - ]; + const simpleColumns = ["DisplayName", "PrimarySmtpAddress", "Identity", "Phone", "Notes"]; return ( { simpleColumns={simpleColumns} cardButton={ <> - } - > - Create Room List - + > } /> ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/spamfilter/list-connectionfilter/index.js b/src/pages/email/spamfilter/list-connectionfilter/index.js index 0ebf8fc05c5b..3ce94c37ec8b 100644 --- a/src/pages/email/spamfilter/list-connectionfilter/index.js +++ b/src/pages/email/spamfilter/list-connectionfilter/index.js @@ -58,5 +58,5 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/spamfilter/list-quarantine-policies/index.js b/src/pages/email/spamfilter/list-quarantine-policies/index.js index fa301cb631f4..471404b92b64 100644 --- a/src/pages/email/spamfilter/list-quarantine-policies/index.js +++ b/src/pages/email/spamfilter/list-quarantine-policies/index.js @@ -23,7 +23,7 @@ import { ApiGetCall } from "/src/api/ApiCall"; const Page = () => { const pageTitle = "Quarantine Policies"; const { currentTenant } = useSettings(); - + const createDialog = useDialog(); // Use ApiGetCall directly as a hook @@ -40,7 +40,6 @@ const Page = () => { const hasGlobalQuarantinePolicyData = !!globalQuarantineData; - if (hasGlobalQuarantinePolicyData) { globalQuarantineData.EndUserSpamNotificationFrequency = globalQuarantineData?.EndUserSpamNotificationFrequency === "P1D" @@ -49,42 +48,40 @@ const Page = () => { ? "Weekly" : globalQuarantineData?.EndUserSpamNotificationFrequency === "PT4H" ? "4 hours" - : globalQuarantineData?.EndUserSpamNotificationFrequency + : globalQuarantineData?.EndUserSpamNotificationFrequency; } const multiLanguagePropertyItems = hasGlobalQuarantinePolicyData - ? ( - Array.isArray(globalQuarantineData?.MultiLanguageSetting) && globalQuarantineData.MultiLanguageSetting.length > 0 - ? globalQuarantineData.MultiLanguageSetting.map((language, idx) => ({ - language: language == "Default" ? "English_USA" - : language == "English" ? "English_GB" - : language, - senderDisplayName: - globalQuarantineData.MultiLanguageSenderName[idx] && - globalQuarantineData.MultiLanguageSenderName[idx].trim() !== "" - ? globalQuarantineData.MultiLanguageSenderName[idx] - : "None", - subject: - globalQuarantineData.EsnCustomSubject[idx] && - globalQuarantineData.EsnCustomSubject[idx].trim() !== "" - ? globalQuarantineData.EsnCustomSubject[idx] - : "None", - disclaimer: - globalQuarantineData.MultiLanguageCustomDisclaimer[idx] && - globalQuarantineData.MultiLanguageCustomDisclaimer[idx].trim() !== "" - ? globalQuarantineData.MultiLanguageCustomDisclaimer[idx] - : "None", - })) - : [ - { - language: "None", - senderDisplayName: "None", - subject: "None", - disclaimer: "None", - }, - ] - ) - : []; + ? Array.isArray(globalQuarantineData?.MultiLanguageSetting) && + globalQuarantineData.MultiLanguageSetting.length > 0 + ? globalQuarantineData.MultiLanguageSetting.map((language, idx) => ({ + language: + language == "Default" ? "English_USA" : language == "English" ? "English_GB" : language, + senderDisplayName: + globalQuarantineData.MultiLanguageSenderName[idx] && + globalQuarantineData.MultiLanguageSenderName[idx].trim() !== "" + ? globalQuarantineData.MultiLanguageSenderName[idx] + : "None", + subject: + globalQuarantineData.EsnCustomSubject[idx] && + globalQuarantineData.EsnCustomSubject[idx].trim() !== "" + ? globalQuarantineData.EsnCustomSubject[idx] + : "None", + disclaimer: + globalQuarantineData.MultiLanguageCustomDisclaimer[idx] && + globalQuarantineData.MultiLanguageCustomDisclaimer[idx].trim() !== "" + ? globalQuarantineData.MultiLanguageCustomDisclaimer[idx] + : "None", + })) + : [ + { + language: "None", + senderDisplayName: "None", + subject: "None", + disclaimer: "None", + }, + ] + : []; const buttonCardActions = [ <> @@ -92,34 +89,28 @@ const Page = () => { Edit Settings - { - GlobalQuarantinePolicy.refetch(); + { + GlobalQuarantinePolicy.refetch(); + }} + > + - - - - + + + - > + >, ]; // Actions to perform (Edit,Delete Policy) @@ -140,8 +131,8 @@ const Page = () => { type: "autoComplete", name: "ReleaseActionPreference", label: "Select release action preference", - multiple : false, - creatable : false, + multiple: false, + creatable: false, options: [ { label: "Release", value: "Release" }, { label: "Request Release", value: "RequestRelease" }, @@ -156,7 +147,7 @@ const Page = () => { type: "switch", name: "Preview", label: "Preview", - }, + }, { type: "switch", name: "BlockSender", @@ -196,11 +187,11 @@ const Page = () => { }, confirmText: ( <> - - Are you sure you want to delete this policy? - + Are you sure you want to delete this policy? - Note: This will delete the Quarantine policy, even if it is currently in use. + Note: This will delete the Quarantine policy, even if it is currently + in use. + Removing the Admin and User Access it applies to emails. @@ -224,7 +215,7 @@ const Page = () => { "Id", // Policy Name/Id "Name", // Policy Name "EndUserQuarantinePermissions", - "Guid", + "Guid", "Builtin", "WhenCreated", // Creation Date "WhenChanged", // Last Modified Date @@ -245,7 +236,6 @@ const Page = () => { }, ]; - const customLanguageOffcanvas = multiLanguagePropertyItems && multiLanguagePropertyItems.length > 0 ? { @@ -269,22 +259,22 @@ const Page = () => { .map(([key, value]) => ( - {key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} - - - {value} + {key + .replace(/([A-Z])/g, " $1") + .replace(/^./, (str) => str.toUpperCase())} + {value} ))} ), })), - } + }, } : {}; - // Simplified columns for the table + // Simplified columns for the table const simpleColumns = [ "Name", "ReleaseActionPreference", @@ -298,8 +288,6 @@ const Page = () => { "WhenChanged", ]; - - // Prepare data for CippInfoBar as a const to clean up the code const infoBarData = [ { @@ -310,31 +298,28 @@ const Page = () => { { icon: , data: hasGlobalQuarantinePolicyData - ? (globalQuarantineData?.OrganizationBrandingEnabled - ? "Enabled" - : "Disabled" - ) + ? globalQuarantineData?.OrganizationBrandingEnabled + ? "Enabled" + : "Disabled" : "n/a", name: "Branding", }, { icon: , - data: hasGlobalQuarantinePolicyData - ? (globalQuarantineData?.EndUserSpamNotificationCustomFromAddress - ? globalQuarantineData?.EndUserSpamNotificationCustomFromAddress - : "None") - : "n/a" , + data: hasGlobalQuarantinePolicyData + ? globalQuarantineData?.EndUserSpamNotificationCustomFromAddress + ? globalQuarantineData?.EndUserSpamNotificationCustomFromAddress + : "None" + : "n/a", name: "Custom Sender Address", }, { icon: , toolTip: "More Info", data: hasGlobalQuarantinePolicyData - ? ( - multiLanguagePropertyItems.length > 0 - ? multiLanguagePropertyItems.map(item => item.language).join(", ") - : "None" - ) + ? multiLanguagePropertyItems.length > 0 + ? multiLanguagePropertyItems.map((item) => item.language).join(", ") + : "None" : "n/a", name: "Custom Language", ...customLanguageOffcanvas, @@ -351,14 +336,11 @@ const Page = () => { > - + - - + + { filters={filterList} simpleColumns={simpleColumns} cardButton={ - <> - } - > - Deploy Custom Policy - - > - } + <> + } + > + Deploy Custom Policy + + > + } /> { Identity: "Guid", }, relatedQueryKeys: [`GlobalQuarantinePolicy-${currentTenant}`], - confirmText: - "Are you sure you want to update Global Quarantine settings?", + confirmText: "Are you sure you want to update Global Quarantine settings?", }} // row={globalQuarantineData} row={globalQuarantineData} @@ -402,8 +383,8 @@ const Page = () => { type: "autoComplete", name: "EndUserSpamNotificationFrequency", label: "Notification Frequency", - multiple : false, - creatable : false, + multiple: false, + creatable: false, required: true, options: [ { label: "4 hours", value: "PT4H" }, @@ -430,6 +411,6 @@ const Page = () => { }; // Layout configuration: ensure page uses DashboardLayout -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/spamfilter/list-spamfilter/index.js b/src/pages/email/spamfilter/list-spamfilter/index.js index f6961ffc7d04..d6a2fe54dd01 100644 --- a/src/pages/email/spamfilter/list-spamfilter/index.js +++ b/src/pages/email/spamfilter/list-spamfilter/index.js @@ -114,5 +114,5 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/tools/mailbox-restores/index.js b/src/pages/email/tools/mailbox-restores/index.js index b63d572a32f3..0cd211b5aa4d 100644 --- a/src/pages/email/tools/mailbox-restores/index.js +++ b/src/pages/email/tools/mailbox-restores/index.js @@ -1,13 +1,13 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Button } from "@mui/material"; -import Link from "next/link"; import { RestoreFromTrash, PlayArrow, Pause, Delete } from "@mui/icons-material"; import MailboxRestoreDetails from "../../../../components/CippComponents/MailboxRestoreDetails"; +import { CippMailboxRestoreDrawer } from "../../../../components/CippComponents/CippMailboxRestoreDrawer"; +import { useSettings } from "/src/hooks/use-settings"; const Page = () => { const pageTitle = "Mailbox Restores"; - + const tenantDomain = useSettings().currentTenant; const actions = [ { label: "Resume Restore Request", @@ -62,17 +62,8 @@ const Page = () => { actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} - cardButton={ - <> - } - > - New Restore Job - - > - } + cardButton={} + queryKey={`MailboxRestores-${tenantDomain}`} /> ); }; diff --git a/src/pages/email/transport/deploy-rules/index.js b/src/pages/email/transport/deploy-rules/index.js deleted file mode 100644 index a12e12a5a58f..000000000000 --- a/src/pages/email/transport/deploy-rules/index.js +++ /dev/null @@ -1,17 +0,0 @@ - -import { Layout as DashboardLayout } from "/src/layouts/index.js"; - -const Page = () => { - const pageTitle = "Deploy Transport rule"; - - return ( - - {pageTitle} - This is a placeholder page for the deploy transport rule section. - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/pages/email/transport/list-connectors/index.js b/src/pages/email/transport/list-connectors/index.js index 6f2f9fe11539..fdb3ca7d92f4 100644 --- a/src/pages/email/transport/list-connectors/index.js +++ b/src/pages/email/transport/list-connectors/index.js @@ -99,5 +99,5 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/email/transport/list-rules/index.js b/src/pages/email/transport/list-rules/index.js index 5faec982d216..db1fcf5a507e 100644 --- a/src/pages/email/transport/list-rules/index.js +++ b/src/pages/email/transport/list-rules/index.js @@ -104,6 +104,13 @@ const Page = () => { > Deploy Template + } + > + New Transport Rule + > } /> diff --git a/src/pages/email/transport/new-rules/add.jsx b/src/pages/email/transport/new-rules/add.jsx new file mode 100644 index 000000000000..76083d0ca3d7 --- /dev/null +++ b/src/pages/email/transport/new-rules/add.jsx @@ -0,0 +1,811 @@ +import React, { useState, useEffect, cloneElement } from "react"; +import { Divider, Typography, Alert, Box } from "@mui/material"; +import { Grid } from "@mui/system"; +import { useForm, useWatch } from "react-hook-form"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import CippFormPage from "/src/components/CippFormPages/CippFormPage"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { CippFormDomainSelector } from "/src/components/CippComponents/CippFormDomainSelector"; +import { useSettings } from "/src/hooks/use-settings"; + +const AddTransportRule = () => { + const currentTenant = useSettings().currentTenant; + const formControl = useForm({ + mode: "onChange", + defaultValues: { + Enabled: true, + Mode: { value: "Enforce", label: "Enforce" }, + StopRuleProcessing: false, + SenderAddressLocation: { value: "Header", label: "Header" }, + applyToAllMessages: false, + tenantFilter: currentTenant + }, + }); + + const [selectedConditions, setSelectedConditions] = useState([]); + const [selectedActions, setSelectedActions] = useState([]); + const [selectedExceptions, setSelectedExceptions] = useState([]); + + const conditionTypeWatch = useWatch({ control: formControl.control, name: "conditionType" }); + const actionTypeWatch = useWatch({ control: formControl.control, name: "actionType" }); + const exceptionTypeWatch = useWatch({ control: formControl.control, name: "exceptionType" }); + const applyToAllMessagesWatch = useWatch({ control: formControl.control, name: "applyToAllMessages" }); + + // Update selected conditions when conditionType changes + useEffect(() => { + setSelectedConditions(conditionTypeWatch || []); + }, [conditionTypeWatch]); + + useEffect(() => { + setSelectedActions(actionTypeWatch || []); + }, [actionTypeWatch]); + + useEffect(() => { + setSelectedExceptions(exceptionTypeWatch || []); + }, [exceptionTypeWatch]); + + // Handle "Apply to all messages" logic + useEffect(() => { + if (applyToAllMessagesWatch) { + // Clear conditions when "apply to all" is enabled + formControl.setValue("conditionType", []); + setSelectedConditions([]); + } + }, [applyToAllMessagesWatch, formControl]); + + // Disable "Apply to all messages" when conditions are selected + useEffect(() => { + if (conditionTypeWatch && conditionTypeWatch.length > 0) { + formControl.setValue("applyToAllMessages", false); + } + }, [conditionTypeWatch, formControl]); + + // Condition options + const conditionOptions = [ + { value: "From", label: "The sender is..." }, + { value: "FromScope", label: "The sender is located..." }, + { value: "SentTo", label: "The recipient is..." }, + { value: "SentToScope", label: "The recipient is located..." }, + { value: "SubjectContainsWords", label: "Subject contains words..." }, + { value: "SubjectMatchesPatterns", label: "Subject matches patterns..." }, + { value: "SubjectOrBodyContainsWords", label: "Subject or body contains words..." }, + { value: "SubjectOrBodyMatchesPatterns", label: "Subject or body matches patterns..." }, + { value: "FromAddressContainsWords", label: "Sender address contains words..." }, + { value: "FromAddressMatchesPatterns", label: "Sender address matches patterns..." }, + { value: "AttachmentContainsWords", label: "Attachment content contains words..." }, + { value: "AttachmentMatchesPatterns", label: "Attachment content matches patterns..." }, + { value: "AttachmentExtensionMatchesWords", label: "Attachment extension is..." }, + { value: "AttachmentSizeOver", label: "Attachment size is greater than..." }, + { value: "MessageSizeOver", label: "Message size is greater than..." }, + { value: "SCLOver", label: "SCL is greater than or equal to..." }, + { value: "WithImportance", label: "Message importance is..." }, + { value: "MessageTypeMatches", label: "Message type is..." }, + { value: "SenderDomainIs", label: "Sender domain is..." }, + { value: "RecipientDomainIs", label: "Recipient domain is..." }, + { value: "HeaderContainsWords", label: "Message header contains words..." }, + { value: "HeaderMatchesPatterns", label: "Message header matches patterns..." }, + ]; + + // Action options + const actionOptions = [ + { value: "DeleteMessage", label: "Delete the message without notifying anyone" }, + { value: "Quarantine", label: "Quarantine the message" }, + { value: "RedirectMessageTo", label: "Redirect the message to..." }, + { value: "BlindCopyTo", label: "Add recipients to the Bcc box..." }, + { value: "CopyTo", label: "Add recipients to the Cc box..." }, + { value: "ModerateMessageByUser", label: "Forward the message for approval to..." }, + { value: "ModerateMessageByManager", label: "Forward the message for approval to the sender's manager" }, + { value: "RejectMessage", label: "Reject the message with explanation..." }, + { value: "PrependSubject", label: "Prepend the subject with..." }, + { value: "SetSCL", label: "Set spam confidence level (SCL) to..." }, + { value: "SetHeader", label: "Set message header..." }, + { value: "RemoveHeader", label: "Remove message header..." }, + { value: "ApplyClassification", label: "Apply message classification..." }, + { value: "ApplyHtmlDisclaimer", label: "Apply HTML disclaimer..." }, + { value: "GenerateIncidentReport", label: "Generate incident report and send to..." }, + { value: "GenerateNotification", label: "Notify the sender with a message..." }, + { value: "ApplyOME", label: "Apply Office 365 Message Encryption" }, + ]; + + const renderConditionField = (condition) => { + const conditionValue = condition.value || condition; + const conditionLabel = condition.label || condition; + + switch (conditionValue) { + case "From": + case "SentTo": + return ( + + `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + dataKey: "Results", + }} + /> + + ); + + case "FromScope": + case "SentToScope": + return ( + + + + ); + + case "WithImportance": + return ( + + + + ); + + case "MessageTypeMatches": + return ( + + + + ); + + case "SCLOver": + return ( + + ({ + value: i.toString(), + label: i.toString(), + }))} + /> + + ); + + case "AttachmentSizeOver": + case "MessageSizeOver": + return ( + + + + ); + + case "SenderDomainIs": + case "RecipientDomainIs": + return ( + + + + ); + + case "HeaderContainsWords": + case "HeaderMatchesPatterns": + return ( + + + + + + + + + + + ); + + default: + return ( + + + + ); + } + }; + + const renderActionField = (action) => { + const actionValue = action.value || action; + const actionLabel = action.label || action; + + switch (actionValue) { + case "DeleteMessage": + case "Quarantine": + case "ModerateMessageByManager": + case "ApplyOME": + return ( + + + + ); + + case "RedirectMessageTo": + case "BlindCopyTo": + case "CopyTo": + case "ModerateMessageByUser": + case "GenerateIncidentReport": + return ( + + `${option.displayName} (${option.userPrincipalName})`, + valueField: "userPrincipalName", + dataKey: "Results", + }} + /> + + ); + + case "SetSCL": + return ( + + ({ + value: i.toString(), + label: i.toString(), + })), + ]} + /> + + ); + + case "RejectMessage": + return ( + + + + + + + + + + + ); + + case "SetHeader": + return ( + + + + + + + + + + + ); + + case "RemoveHeader": + return ( + + + + ); + + case "ApplyHtmlDisclaimer": + return ( + + + + + + + + + + + + + + ); + + case "PrependSubject": + case "ApplyClassification": + case "GenerateNotification": + return ( + + + + ); + + default: + return ( + + + + ); + } + }; + + const renderExceptionField = (exception) => { + const exceptionValue = exception.value || exception; + const baseCondition = exceptionValue.replace("ExceptIf", ""); + const exceptionLabel = exception.label || exception; + + // Reuse the condition rendering logic + const mockCondition = { value: baseCondition, label: exceptionLabel }; + const field = renderConditionField(mockCondition); + + // Update the field's name to include ExceptIf prefix + if (field) { + return cloneElement(field, { + key: exceptionValue, + children: React.Children.map(field.props.children, (child) => { + if (child?.type === CippFormComponent) { + return cloneElement(child, { + name: exceptionValue, + }); + } + // For Grid containers with multiple fields (like HeaderContains) + if (child?.type === Grid && child.props.container) { + return cloneElement(child, { + children: React.Children.map(child.props.children, (gridChild) => { + if (gridChild?.props?.children?.type === CippFormComponent) { + const formComponent = gridChild.props.children; + const originalName = formComponent.props.name; + const newName = originalName.includes("MessageHeader") + ? `ExceptIf${originalName}` + : exceptionValue; + return cloneElement(gridChild, { + children: cloneElement(formComponent, { + name: newName, + }), + }); + } + return gridChild; + }), + }); + } + return child; + }), + }); + } + return null; + }; + + return ( + + + + + {/* Basic Information */} + + + Basic Information + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Conditions */} + + + Apply this rule if... + + + + + + + + {applyToAllMessagesWatch && ( + + + This rule will apply to ALL inbound and outbound messages + for the entire organization. + + + )} + + {!applyToAllMessagesWatch && ( + <> + + + Select one or more conditions to target specific messages. If you want this rule to + apply to all messages, enable "Apply to all messages" above. + + + + + + + + {selectedConditions.map((condition) => renderConditionField(condition))} + > + )} + + + + {/* Actions */} + + + Do the following... + + + + + + + + {selectedActions.map((action) => renderActionField(action))} + + + + {/* Exceptions */} + + + Except if... (optional) + + + + + ({ + value: `ExceptIf${opt.value}`, + label: opt.label, + }))} + /> + + + {selectedExceptions.map((exception) => renderExceptionField(exception))} + + + + {/* Advanced Settings */} + + + Advanced Settings + + + + + + + + + + + + + + + + + + + + + + ); +}; + +AddTransportRule.getLayout = (page) => {page}; + +export default AddTransportRule; \ No newline at end of file diff --git a/src/pages/endpoint/MEM/assignment-filter-templates/add.jsx b/src/pages/endpoint/MEM/assignment-filter-templates/add.jsx new file mode 100644 index 000000000000..ca731ffd54cb --- /dev/null +++ b/src/pages/endpoint/MEM/assignment-filter-templates/add.jsx @@ -0,0 +1,37 @@ +import { Box } from "@mui/material"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { useForm } from "react-hook-form"; +import { useSettings } from "../../../../hooks/use-settings"; +import CippAddAssignmentFilterTemplateForm from "../../../../components/CippFormPages/CippAddAssignmentFilterTemplateForm"; +const Page = () => { + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + }, + }); + + return ( + <> + + + + + + > + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/assignment-filter-templates/deploy.js b/src/pages/endpoint/MEM/assignment-filter-templates/deploy.js new file mode 100644 index 000000000000..5c5f9c0ce1df --- /dev/null +++ b/src/pages/endpoint/MEM/assignment-filter-templates/deploy.js @@ -0,0 +1,43 @@ +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { CippWizardConfirmation } from "/src/components/CippWizard/CippWizardConfirmation"; +import CippWizardPage from "/src/components/CippWizard/CippWizardPage.jsx"; +import { CippTenantStep } from "/src/components/CippWizard/CippTenantStep.jsx"; +import { CippWizardAssignmentFilterTemplates } from "../../../../components/CippWizard/CippWizardAssignmentFilterTemplates"; + +const Page = () => { + const steps = [ + { + title: "Step 1", + description: "Tenant Selection", + component: CippTenantStep, + componentProps: { + allTenants: false, + type: "multiple", + }, + }, + { + title: "Step 2", + description: "Choose Template", + component: CippWizardAssignmentFilterTemplates, + }, + { + title: "Step 3", + description: "Confirmation", + component: CippWizardConfirmation, + }, + ]; + + return ( + <> + + > + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/assignment-filter-templates/edit.jsx b/src/pages/endpoint/MEM/assignment-filter-templates/edit.jsx new file mode 100644 index 000000000000..2d10d7502e61 --- /dev/null +++ b/src/pages/endpoint/MEM/assignment-filter-templates/edit.jsx @@ -0,0 +1,77 @@ +import { Box, CircularProgress } from "@mui/material"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { useForm } from "react-hook-form"; +import { useSettings } from "../../../../hooks/use-settings"; +import CippAddAssignmentFilterTemplateForm from "../../../../components/CippFormPages/CippAddAssignmentFilterTemplateForm"; +import { useRouter } from "next/router"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import { useEffect } from "react"; + +const Page = () => { + const userSettingsDefaults = useSettings(); + const router = useRouter(); + const { id } = router.query; + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: userSettingsDefaults.currentTenant, + }, + }); + + // Fetch template data + const { data: template, isFetching } = ApiGetCall({ + url: `/api/ListAssignmentFilterTemplates?id=${id}`, + queryKey: `AssignmentFilterTemplate-${id}`, + waiting: !!id, + }); + + // Map groupType values to valid radio options + + // Set form values when template data is loaded + useEffect(() => { + if (template) { + const templateData = template[0]; + + // Make sure we have the necessary data before proceeding + if (templateData) { + formControl.reset({ + GUID: templateData.GUID, + displayName: templateData.displayName, + description: templateData.description, + platform: templateData.platform, + rule: templateData.rule, + assignmentFilterManagementType: templateData.assignmentFilterManagementType, + tenantFilter: userSettingsDefaults.currentTenant, + }); + } + } + }, [template, formControl, userSettingsDefaults.currentTenant]); + + return ( + <> + + {/* Add debugging output to check what values are set */} + {JSON.stringify(formControl.watch(), null, 2)} + + + + + + > + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/assignment-filter-templates/index.js b/src/pages/endpoint/MEM/assignment-filter-templates/index.js new file mode 100644 index 000000000000..b3736a0fc346 --- /dev/null +++ b/src/pages/endpoint/MEM/assignment-filter-templates/index.js @@ -0,0 +1,133 @@ +import { Button } from "@mui/material"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { AddBox, RocketLaunch, Delete, GitHub, Edit } from "@mui/icons-material"; +import Link from "next/link"; +import { ApiGetCall } from "/src/api/ApiCall"; +import { CippPropertyListCard } from "../../../../components/CippCards/CippPropertyListCard"; +import { getCippTranslation } from "../../../../utils/get-cipp-translation"; +import { getCippFormatting } from "../../../../utils/get-cipp-formatting"; + +const Page = () => { + const pageTitle = "Assignment Filter Templates"; + const integrations = ApiGetCall({ + url: "/api/ListExtensionsConfig", + queryKey: "Integrations", + refetchOnMount: false, + refetchOnReconnect: false, + }); + const actions = [ + { + label: "Edit Template", + icon: , + link: "/endpoint/MEM/assignment-filter-templates/edit?id=[GUID]", + }, + { + label: "Save to GitHub", + type: "POST", + url: "/api/ExecCommunityRepo", + icon: , + data: { + Action: "UploadTemplate", + GUID: "GUID", + }, + fields: [ + { + label: "Repository", + name: "FullName", + type: "select", + api: { + url: "/api/ListCommunityRepos", + data: { + WriteAccess: true, + }, + queryKey: "CommunityRepos-Write", + dataKey: "Results", + valueField: "FullName", + labelField: "FullName", + }, + multiple: false, + creatable: false, + required: true, + validators: { + required: { value: true, message: "This field is required" }, + }, + }, + { + label: "Commit Message", + placeholder: "Enter a commit message for adding this file to GitHub", + name: "Message", + type: "textField", + multiline: true, + required: true, + rows: 4, + }, + ], + confirmText: "Are you sure you want to save this template to the selected repository?", + condition: () => integrations.isSuccess && integrations?.data?.GitHub?.Enabled, + }, + { + label: "Delete Template", + type: "POST", + url: "/api/RemoveAssignmentFilterTemplate", + icon: , + data: { + ID: "GUID", + }, + confirmText: "Do you want to delete the template?", + multiPost: false, + }, + ]; + + const offCanvas = { + children: (data) => { + const keys = Object.keys(data).filter( + (key) => !key.includes("@odata") && !key.includes("@data") + ); + const properties = []; + keys.forEach((key) => { + if (data[key] && data[key].length > 0) { + properties.push({ + label: getCippTranslation(key), + value: getCippFormatting(data[key], key), + }); + } + }); + return ( + + ); + }, + }; + + return ( + + }> + Add Assignment Filter Template + + }> + Deploy Assignment Filter Template + + > + } + offCanvas={offCanvas} + simpleColumns={["displayName", "description", "platform", "GUID"]} + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/assignment-filters/add.jsx b/src/pages/endpoint/MEM/assignment-filters/add.jsx new file mode 100644 index 000000000000..68c8cb027983 --- /dev/null +++ b/src/pages/endpoint/MEM/assignment-filters/add.jsx @@ -0,0 +1,42 @@ +import { Box } from "@mui/material"; +import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { useForm } from "react-hook-form"; +import { useSettings } from "../../../../hooks/use-settings"; +import { useEffect } from "react"; +import CippAddAssignmentFilterForm from "../../../../components/CippFormPages/CippAddAssignmentFilterForm"; + +const Page = () => { + const userSettingsDefaults = useSettings(); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + assignmentFilterManagementType: "devices", + }, + }); + + useEffect(() => { + formControl.setValue("tenantFilter", userSettingsDefaults?.currentTenant || ""); + }, [userSettingsDefaults, formControl]); + + return ( + <> + + + + + + > + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/assignment-filters/edit.jsx b/src/pages/endpoint/MEM/assignment-filters/edit.jsx new file mode 100644 index 000000000000..0fae5e9b6b0d --- /dev/null +++ b/src/pages/endpoint/MEM/assignment-filters/edit.jsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from "react"; +import { Box } from "@mui/material"; +import { useForm } from "react-hook-form"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import CippFormPage from "/src/components/CippFormPages/CippFormPage"; +import { useRouter } from "next/router"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import { useSettings } from "../../../../hooks/use-settings"; +import CippAddAssignmentFilterForm from "../../../../components/CippFormPages/CippAddAssignmentFilterForm"; + +const EditAssignmentFilter = () => { + const router = useRouter(); + const { filterId } = router.query; + const [filterIdReady, setFilterIdReady] = useState(false); + const tenantFilter = useSettings().currentTenant; + + const filterInfo = ApiGetCall({ + url: `/api/ListAssignmentFilters?filterId=${filterId}&tenantFilter=${tenantFilter}`, + queryKey: `ListAssignmentFilters-${filterId}`, + waiting: filterIdReady, + }); + + useEffect(() => { + if (filterId) { + setFilterIdReady(true); + filterInfo.refetch(); + } + }, [router.query, filterId, tenantFilter]); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + tenantFilter: tenantFilter, + assignmentFilterManagementType: "devices", + }, + }); + + useEffect(() => { + if (filterInfo.isSuccess && filterInfo.data) { + const filter = Array.isArray(filterInfo.data) ? filterInfo.data[0] : filterInfo.data; + + if (filter) { + const formValues = { + tenantFilter: tenantFilter, + filterId: filter.id, + displayName: filter.displayName || "", + description: filter.description || "", + platform: filter.platform || "", + rule: filter.rule || "", + assignmentFilterManagementType: filter.assignmentFilterManagementType || "devices", + }; + + formControl.reset(formValues); + } + } + }, [filterInfo.isSuccess, filterInfo.data, tenantFilter]); + + return ( + <> + + + + + + > + ); +}; + +EditAssignmentFilter.getLayout = (page) => {page}; + +export default EditAssignmentFilter; diff --git a/src/pages/endpoint/MEM/assignment-filters/index.js b/src/pages/endpoint/MEM/assignment-filters/index.js new file mode 100644 index 000000000000..0a69dcd1460b --- /dev/null +++ b/src/pages/endpoint/MEM/assignment-filters/index.js @@ -0,0 +1,92 @@ +import { Button } from "@mui/material"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import Link from "next/link"; +import { TrashIcon } from "@heroicons/react/24/outline"; +import { FilterAlt, Edit, Add } from "@mui/icons-material"; +import { Stack } from "@mui/system"; +import { useSettings } from "../../../../hooks/use-settings"; + +const Page = () => { + const pageTitle = "Assignment Filters"; + const { currentTenant } = useSettings(); + + const actions = [ + { + label: "Edit Filter", + link: "/endpoint/MEM/assignment-filters/edit?filterId=[id]", + multiPost: false, + icon: , + color: "success", + }, + { + label: "Create template based on filter", + type: "POST", + url: "/api/AddAssignmentFilterTemplate", + icon: , + data: { + displayName: "displayName", + description: "description", + platform: "platform", + rule: "rule", + assignmentFilterManagementType: "assignmentFilterManagementType", + }, + confirmText: "Are you sure you want to create a template based on this filter?", + multiPost: false, + }, + { + label: "Delete Filter", + type: "POST", + url: "/api/ExecAssignmentFilter", + icon: , + data: { + ID: "id", + Action: "Delete", + }, + confirmText: "Are you sure you want to delete this assignment filter?", + multiPost: false, + }, + ]; + + const offCanvas = { + extendedInfoFields: [ + "displayName", + "description", + "id", + "platform", + "rule", + "assignmentFilterManagementType", + "createdDateTime", + "lastModifiedDateTime", + ], + actions: actions, + }; + + return ( + + }> + Add Assignment Filter + + + } + apiUrl="/api/ListAssignmentFilters" + queryKey={`assignment-filters-${currentTenant}`} + actions={actions} + offCanvas={offCanvas} + simpleColumns={[ + "displayName", + "description", + "platform", + "assignmentFilterManagementType", + "rule", + ]} + /> + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/endpoint/MEM/devices/index.js b/src/pages/endpoint/MEM/devices/index.js index 14b4c6feae12..8c2cf07cbcfa 100644 --- a/src/pages/endpoint/MEM/devices/index.js +++ b/src/pages/endpoint/MEM/devices/index.js @@ -68,7 +68,7 @@ const Page = () => { }, }, ], - confirmText: "Select the User to set as the primary user for this device", + confirmText: "Select the User to set as the primary user for [deviceName]", }, { label: "Rename Device", @@ -98,7 +98,7 @@ const Page = () => { GUID: "id", Action: "syncDevice", }, - confirmText: "Are you sure you want to sync this device?", + confirmText: "Are you sure you want to sync [deviceName]?", }, { label: "Reboot Device", @@ -109,7 +109,7 @@ const Page = () => { GUID: "id", Action: "rebootNow", }, - confirmText: "Are you sure you want to reboot this device?", + confirmText: "Are you sure you want to reboot [deviceName]?", }, { label: "Locate Device", @@ -120,7 +120,7 @@ const Page = () => { GUID: "id", Action: "locateDevice", }, - confirmText: "Are you sure you want to locate this device?", + confirmText: "Are you sure you want to locate [deviceName]?", }, { label: "Retrieve LAPs password", @@ -130,6 +130,7 @@ const Page = () => { data: { GUID: "azureADDeviceId", }, + condition: (row) => row.operatingSystem === "Windows", confirmText: "Are you sure you want to retrieve the local admin password?", }, { @@ -141,7 +142,8 @@ const Page = () => { GUID: "id", Action: "RotateLocalAdminPassword", }, - confirmText: "Are you sure you want to rotate the password for this device?", + condition: (row) => row.operatingSystem === "Windows", + confirmText: "Are you sure you want to rotate the password for [deviceName]?", }, { label: "Retrieve BitLocker Keys", @@ -151,6 +153,7 @@ const Page = () => { data: { GUID: "azureADDeviceId", }, + condition: (row) => row.operatingSystem === "Windows", confirmText: "Are you sure you want to retrieve the BitLocker keys?", }, { @@ -163,7 +166,7 @@ const Page = () => { Action: "WindowsDefenderScan", quickScan: false, }, - confirmText: "Are you sure you want to perform a full scan on this device?", + confirmText: "Are you sure you want to perform a full scan on [deviceName]?", }, { label: "Windows Defender Quick Scan", @@ -175,7 +178,7 @@ const Page = () => { Action: "WindowsDefenderScan", quickScan: true, }, - confirmText: "Are you sure you want to perform a quick scan on this device?", + confirmText: "Are you sure you want to perform a quick scan on [deviceName]?", }, { label: "Update Windows Defender", @@ -187,7 +190,7 @@ const Page = () => { Action: "windowsDefenderUpdateSignatures", }, confirmText: - "Are you sure you want to update the Windows Defender signatures for this device?", + "Are you sure you want to update the Windows Defender signatures for [deviceName]?", }, { label: "Generate logs and ship to MEM", @@ -196,9 +199,11 @@ const Page = () => { url: "/api/ExecDeviceAction", data: { GUID: "id", - Action: "CreateDeviceLogCollectionRequest", + Action: "createDeviceLogCollectionRequest", }, - confirmText: "Are you sure you want to generate logs and ship these to MEM?", + condition: (row) => row.operatingSystem === "Windows", + confirmText: + "Are you sure you want to generate logs for device [deviceName] and ship these to MEM?", }, { label: "Fresh Start (Remove user data)", @@ -210,7 +215,8 @@ const Page = () => { Action: "cleanWindowsDevice", keepUserData: false, }, - confirmText: "Are you sure you want to Fresh Start this device?", + condition: (row) => row.operatingSystem === "Windows", + confirmText: "Are you sure you want to Fresh Start [deviceName]?", }, { label: "Fresh Start (Do not remove user data)", @@ -222,7 +228,8 @@ const Page = () => { Action: "cleanWindowsDevice", keepUserData: true, }, - confirmText: "Are you sure you want to Fresh Start this device?", + condition: (row) => row.operatingSystem === "Windows", + confirmText: "Are you sure you want to Fresh Start [deviceName]?", }, { label: "Wipe Device, keep enrollment data", @@ -235,7 +242,8 @@ const Page = () => { keepUserData: false, keepEnrollmentData: true, }, - confirmText: "Are you sure you want to wipe this device, and retain enrollment data?", + condition: (row) => row.operatingSystem === "Windows", + confirmText: "Are you sure you want to wipe [deviceName], and retain enrollment data?", }, { label: "Wipe Device, remove enrollment data", @@ -248,7 +256,8 @@ const Page = () => { keepUserData: false, keepEnrollmentData: false, }, - confirmText: "Are you sure you want to wipe this device, and remove enrollment data?", + condition: (row) => row.operatingSystem === "Windows", + confirmText: "Are you sure you want to wipe [deviceName], and remove enrollment data?", }, { label: "Wipe Device, keep enrollment data, and continue at powerloss", @@ -262,8 +271,9 @@ const Page = () => { keepUserData: false, useProtectedWipe: true, }, + condition: (row) => row.operatingSystem === "Windows", confirmText: - "Are you sure you want to wipe this device? This will retain enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.", + "Are you sure you want to wipe [deviceName]? This will retain enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.", }, { label: "Wipe Device, remove enrollment data, and continue at powerloss", @@ -277,8 +287,9 @@ const Page = () => { keepUserData: false, useProtectedWipe: true, }, + condition: (row) => row.operatingSystem === "Windows", confirmText: - "Are you sure you want to wipe this device? This will also remove enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.", + "Are you sure you want to wipe [deviceName]? This will also remove enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.", }, { label: "Autopilot Reset", @@ -291,7 +302,8 @@ const Page = () => { keepUserData: "false", keepEnrollmentData: "true", }, - confirmText: "Are you sure you want to Autopilot Reset this device?", + condition: (row) => row.operatingSystem === "Windows", + confirmText: "Are you sure you want to Autopilot Reset [deviceName]?", }, { label: "Delete device", @@ -302,7 +314,7 @@ const Page = () => { GUID: "id", Action: "delete", }, - confirmText: "Are you sure you want to retire this device?", + confirmText: "Are you sure you want to delete [deviceName]?", }, { label: "Retire device", @@ -313,7 +325,7 @@ const Page = () => { GUID: "id", Action: "retire", }, - confirmText: "Are you sure you want to retire this device?", + confirmText: "Are you sure you want to retire [deviceName]?", }, ]; diff --git a/src/pages/endpoint/MEM/list-policies/index.js b/src/pages/endpoint/MEM/list-policies/index.js index bf23a754fb27..2a3b15d90c26 100644 --- a/src/pages/endpoint/MEM/list-policies/index.js +++ b/src/pages/endpoint/MEM/list-policies/index.js @@ -1,7 +1,7 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; import { Book, LaptopChromebook } from "@mui/icons-material"; -import { GlobeAltIcon, TrashIcon, UserIcon } from "@heroicons/react/24/outline"; +import { GlobeAltIcon, TrashIcon, UserIcon, UserGroupIcon } from "@heroicons/react/24/outline"; import { PermissionButton } from "/src/utils/permissions.js"; import { CippPolicyDeployDrawer } from "/src/components/CippComponents/CippPolicyDeployDrawer.jsx"; @@ -61,6 +61,26 @@ const Page = () => { icon: , color: "info", }, + { + label: "Assign to Custom Group", + type: "POST", + url: "/api/ExecAssignPolicy", + data: { + ID: "id", + type: "URLName", + }, + confirmText: "Enter the name of the group to assign this policy to. Wildcards (*) are allowed.", + icon: , + color: "info", + fields: [ + { + type: "textField", + name: "AssignTo", + label: "Group Name(s), optionally comma-separated", + placeholder: "IT-*, Sales Team", + }, + ], + }, { label: "Delete Policy", type: "POST", diff --git a/src/pages/endpoint/MEM/list-templates/index.js b/src/pages/endpoint/MEM/list-templates/index.js index 458f090ba53c..2c06faf5f4da 100644 --- a/src/pages/endpoint/MEM/list-templates/index.js +++ b/src/pages/endpoint/MEM/list-templates/index.js @@ -1,7 +1,7 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; import { PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; -import { Edit, GitHub, LocalOffer } from "@mui/icons-material"; +import { Edit, GitHub, LocalOffer, LocalOfferOutlined } from "@mui/icons-material"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; import { ApiGetCall } from "/src/api/ApiCall"; import { CippPolicyImportDrawer } from "/src/components/CippComponents/CippPolicyImportDrawer.jsx"; @@ -68,6 +68,16 @@ const Page = () => { icon: , color: "info", }, + { + label: "Remove from package", + type: "POST", + url: "/api/ExecSetPackageTag", + data: { GUID: "GUID", Remove: true }, + confirmText: "Are you sure you want to remove the selected template(s) from their package?", + multiPost: true, + icon: , + color: "warning", + }, { label: "Save to GitHub", type: "POST", @@ -125,7 +135,7 @@ const Page = () => { ]; const offCanvas = { - children: (row) => , + children: (row) => , size: "lg", }; diff --git a/src/pages/endpoint/autopilot/list-profiles/index.js b/src/pages/endpoint/autopilot/list-profiles/index.js index c1dc97e21c84..63626a661e54 100644 --- a/src/pages/endpoint/autopilot/list-profiles/index.js +++ b/src/pages/endpoint/autopilot/list-profiles/index.js @@ -21,13 +21,13 @@ const Page = () => { ]; const offCanvas = { - children: (row) => , + children: (row) => , size: "xl", }; const simpleColumns = [ "displayName", - "Description", + "description", "language", "extractHardwareHash", "deviceNameTemplate", @@ -36,7 +36,12 @@ const Page = () => { return ( { const filterList = [ { filterName: "Failed Deployments", - value: [{ id: "deploymentState", value: "failed" }], + value: [{ id: "deploymentState", value: "failure" }], type: "column", }, { @@ -109,4 +109,4 @@ const Page = () => { Page.getLayout = (page) => {page}; -export default Page; \ No newline at end of file +export default Page; diff --git a/src/pages/identity/administration/jit-admin/add.jsx b/src/pages/identity/administration/jit-admin/add.jsx index fd3f8e5ddfe0..48e9bf2255a0 100644 --- a/src/pages/identity/administration/jit-admin/add.jsx +++ b/src/pages/identity/administration/jit-admin/add.jsx @@ -10,7 +10,7 @@ import gdaproles from "/src/data/GDAPRoles.json"; import { CippFormDomainSelector } from "../../../../components/CippComponents/CippFormDomainSelector"; import { CippFormUserSelector } from "../../../../components/CippComponents/CippFormUserSelector"; const Page = () => { - const formControl = useForm({ Mode: "onChange" }); + const formControl = useForm({ mode: "onChange" }); return ( <> { { label: "Existing User", value: "select" }, ]} required={true} + validators={{ required: "You must select an option" }} /> @@ -62,6 +63,7 @@ const Page = () => { name="firstName" formControl={formControl} required={true} + validators={{ required: "First Name is required" }} /> @@ -72,6 +74,7 @@ const Page = () => { name="lastName" formControl={formControl} required={true} + validators={{ required: "Last Name is required" }} /> @@ -82,6 +85,7 @@ const Page = () => { name="userName" formControl={formControl} required={true} + validators={{ required: "Username is required" }} /> @@ -90,14 +94,7 @@ const Page = () => { name="domain" label="Domain Name" required={true} - validators={{ - validate: (option) => { - if (!option?.value) { - return "Domain is required"; - } - return true; - }, - }} + validators={{ required: "Domain is required" }} /> @@ -118,6 +115,7 @@ const Page = () => { name="existingUser" label="User" required={true} + validators={{ required: "User is required" }} /> @@ -130,14 +128,7 @@ const Page = () => { name="startDate" formControl={formControl} required={true} - validators={{ - validate: (value) => { - if (!value) { - return "Start date is required"; - } - return true; - }, - }} + validators={{ required: "Start date is required" }} /> @@ -149,12 +140,10 @@ const Page = () => { formControl={formControl} required={true} validators={{ + required: "End date is required", validate: (value) => { const startDate = formControl.getValues("startDate"); - if (!value) { - return "End date is required"; - } - if (new Date(value) < new Date(startDate)) { + if (value && startDate && new Date(value) < new Date(startDate)) { return "End date must be after start date"; } return true; @@ -172,6 +161,7 @@ const Page = () => { formControl={formControl} required={true} validators={{ + required: "At least one role is required", validate: (options) => { if (!options?.length) { return "At least one role is required"; @@ -181,6 +171,19 @@ const Page = () => { }} /> + + + { label="Expiration Action" name="expireAction" multiple={false} + required={true} options={[ { label: "Delete User", value: "DeleteUser" }, { label: "Disable User", value: "DisableUser" }, { label: "Remove Roles", value: "RemoveRoles" }, ]} formControl={formControl} - required={true} - validators={{ - validate: (option) => { - if (!option?.value) { - return "Expiration action is required"; - } - return true; - }, - }} + validators={{ required: "Expiration action is required" }} /> diff --git a/src/pages/identity/administration/users/patch-wizard.jsx b/src/pages/identity/administration/users/patch-wizard.jsx index 8ea240386296..1fece737f175 100644 --- a/src/pages/identity/administration/users/patch-wizard.jsx +++ b/src/pages/identity/administration/users/patch-wizard.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useRouter } from "next/router"; import { Layout as DashboardLayout } from "/src/layouts/index.js"; import CippWizardPage from "/src/components/CippWizard/CippWizardPage.jsx"; @@ -17,7 +17,7 @@ import { Autocomplete, } from "@mui/material"; import { CippWizardStepButtons } from "/src/components/CippWizard/CippWizardStepButtons"; -import { ApiPostCall } from "/src/api/ApiCall"; +import { ApiPostCall, ApiGetCall } from "/src/api/ApiCall"; import { CippApiResults } from "/src/components/CippComponents/CippApiResults"; import { CippDataTable } from "/src/components/CippTable/CippDataTable"; import { Delete } from "@mui/icons-material"; @@ -176,9 +176,48 @@ const UsersDisplayStep = (props) => { // Step 2: Property selection and input const PropertySelectionStep = (props) => { - const { onNextStep, onPreviousStep, formControl, currentStep } = props; + const { onNextStep, onPreviousStep, formControl, currentStep, users } = props; const [selectedProperties, setSelectedProperties] = useState([]); + // Get unique tenant domains from users + const tenantDomains = + [...new Set(users?.map((user) => user.Tenant || user.tenantFilter).filter(Boolean))] || []; + + // Fetch custom data mappings for all tenants + const customDataMappings = ApiGetCall({ + url: + tenantDomains.length > 0 + ? `/api/ListCustomDataMappings?sourceType=Manual Entry&directoryObject=User&tenantFilter=${tenantDomains[0]}` + : null, + queryKey: `ManualEntryMappings-${tenantDomains.join(",")}`, + enabled: tenantDomains.length > 0, + refetchOnMount: false, + refetchOnReconnect: false, + }); + + // Process custom data mappings into property format + const customDataProperties = useMemo(() => { + if (customDataMappings.isSuccess && customDataMappings.data?.Results) { + return customDataMappings.data.Results.filter((mapping) => { + // Only include single-value properties, filter out multivalue ones + const dataType = mapping.customDataAttribute.addedFields.dataType?.toLowerCase(); + const isMultiValue = mapping.customDataAttribute.addedFields.isMultiValue; + return !isMultiValue && dataType !== "collection"; + }).map((mapping) => ({ + property: mapping.customDataAttribute.value, // Use the actual attribute name, not nested under customData + label: `${mapping.manualEntryFieldLabel} (Custom)`, + type: mapping.customDataAttribute.addedFields.dataType?.toLowerCase() || "string", + isCustomData: true, + })); + } + return []; + }, [customDataMappings.isSuccess, customDataMappings.data]); + + // Combine standard properties with custom data properties + const allProperties = useMemo(() => { + return [...PATCHABLE_PROPERTIES, ...customDataProperties]; + }, [customDataProperties]); + // Register form fields formControl.register("selectedProperties", { required: true }); formControl.register("propertyValues", { required: false }); @@ -191,7 +230,7 @@ const PropertySelectionStep = (props) => { }; const renderPropertyInput = (propertyName) => { - const property = PATCHABLE_PROPERTIES.find((p) => p.property === propertyName); + const property = allProperties.find((p) => p.property === propertyName); const currentValue = formControl.getValues("propertyValues")?.[propertyName]; if (property?.type === "boolean") { @@ -245,7 +284,15 @@ const PropertySelectionStep = (props) => { Select Properties to update Choose which user properties you want to modify and provide the new values. + {customDataProperties.length > 0 && ( + <> Custom data fields are available based on your tenant's manual entry mappings.> + )} + {customDataMappings.isLoading && ( + + Loading custom data mappings... + + )} { options={[ { property: "select-all", - label: `Select All (${PATCHABLE_PROPERTIES.length} properties)`, + label: `Select All (${allProperties.length} properties)`, isSelectAll: true, }, - ...PATCHABLE_PROPERTIES, + ...allProperties, ]} - value={PATCHABLE_PROPERTIES.filter((prop) => selectedProperties.includes(prop.property))} + value={allProperties.filter((prop) => selectedProperties.includes(prop.property))} onChange={(event, newValue) => { // Check if "Select All" was clicked const selectAllOption = newValue.find((option) => option.isSelectAll); if (selectAllOption) { // If Select All is in the new value, select all properties - const allSelected = selectedProperties.length === PATCHABLE_PROPERTIES.length; - const newProperties = allSelected ? [] : PATCHABLE_PROPERTIES.map((p) => p.property); + const allSelected = selectedProperties.length === allProperties.length; + const newProperties = allSelected ? [] : allProperties.map((p) => p.property); setSelectedProperties(newProperties); formControl.setValue("selectedProperties", newProperties); @@ -305,10 +352,9 @@ const PropertySelectionStep = (props) => { isOptionEqualToValue={(option, value) => option.property === value.property} size="small" renderOption={(props, option, { selected }) => { - const isAllSelected = selectedProperties.length === PATCHABLE_PROPERTIES.length; + const isAllSelected = selectedProperties.length === allProperties.length; const isIndeterminate = - selectedProperties.length > 0 && - selectedProperties.length < PATCHABLE_PROPERTIES.length; + selectedProperties.length > 0 && selectedProperties.length < allProperties.length; if (option.isSelectAll) { return ( @@ -390,7 +436,7 @@ const PropertySelectionStep = (props) => { // Step 3: Confirmation const ConfirmationStep = (props) => { - const { lastStep, formControl, onPreviousStep, currentStep, users } = props; + const { lastStep, formControl, onPreviousStep, currentStep, users, allProperties } = props; const formValues = formControl.getValues(); const { selectedProperties = [], propertyValues = {} } = formValues; @@ -412,11 +458,31 @@ const ConfirmationStep = (props) => { id: user.id, tenantFilter: user.Tenant || user.tenantFilter, }; + selectedProperties.forEach((propName) => { if (propertyValues[propName] !== undefined && propertyValues[propName] !== "") { - userData[propName] = propertyValues[propName]; + // Handle dot notation properties (e.g., "extension_abc123.customField") + if (propName.includes(".")) { + const parts = propName.split("."); + let current = userData; + + // Navigate to the nested object, creating it if it doesn't exist + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]]) { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + + // Set the final property value + current[parts[parts.length - 1]] = propertyValues[propName]; + } else { + // Handle regular properties + userData[propName] = propertyValues[propName]; + } } }); + return userData; }); @@ -459,7 +525,7 @@ const ConfirmationStep = (props) => { {selectedProperties.map((propName) => { - const property = PATCHABLE_PROPERTIES.find((p) => p.property === propName); + const property = allProperties.find((p) => p.property === propName); const value = propertyValues[propName]; const displayValue = property?.type === "boolean" ? (value ? "Yes" : "No") : value || "Not set"; @@ -565,6 +631,48 @@ const Page = () => { } }, [router.query.users]); + // Get unique tenant domains from users + const tenantDomains = useMemo(() => { + return ( + [...new Set(users?.map((user) => user.Tenant || user.tenantFilter).filter(Boolean))] || [] + ); + }, [users]); + + // Fetch custom data mappings for all tenants + const customDataMappings = ApiGetCall({ + url: + tenantDomains.length > 0 + ? `/api/ListCustomDataMappings?sourceType=Manual Entry&directoryObject=User&tenantFilter=${tenantDomains[0]}` + : null, + queryKey: `ManualEntryMappings-${tenantDomains.join(",")}`, + enabled: tenantDomains.length > 0, + refetchOnMount: false, + refetchOnReconnect: false, + }); + + // Process custom data mappings into property format + const customDataProperties = useMemo(() => { + if (customDataMappings.isSuccess && customDataMappings.data?.Results) { + return customDataMappings.data.Results.filter((mapping) => { + // Only include single-value properties, filter out multivalue ones + const dataType = mapping.customDataAttribute.addedFields.dataType?.toLowerCase(); + const isMultiValue = mapping.customDataAttribute.addedFields.isMultiValue; + return !isMultiValue && dataType !== "collection"; + }).map((mapping) => ({ + property: mapping.customDataAttribute.value, // Use the actual attribute name, not nested under customData + label: `${mapping.manualEntryFieldLabel} (Custom)`, + type: mapping.customDataAttribute.addedFields.dataType?.toLowerCase() || "string", + isCustomData: true, + })); + } + return []; + }, [customDataMappings.isSuccess, customDataMappings.data]); + + // Combine standard properties with custom data properties + const allProperties = useMemo(() => { + return [...PATCHABLE_PROPERTIES, ...customDataProperties]; + }, [customDataProperties]); + const steps = [ { title: "Step 1", @@ -579,6 +687,12 @@ const Page = () => { title: "Step 2", description: "Select Properties", component: PropertySelectionStep, + componentProps: { + users: users, + allProperties: allProperties, + customDataMappings: customDataMappings, + customDataProperties: customDataProperties, + }, }, { title: "Step 3", @@ -586,6 +700,7 @@ const Page = () => { component: ConfirmationStep, componentProps: { users: users, + allProperties: allProperties, }, }, ]; diff --git a/src/pages/index.js b/src/pages/index.js index 4aaa3606a4f1..4d8478b2f58e 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -32,16 +32,6 @@ const Page = () => { queryKey: `${currentTenant}-ListuserCounts`, }); - const GlobalAdminList = ApiGetCall({ - url: "/api/ListGraphRequest", - queryKey: `${currentTenant}-ListGraphRequest`, - data: { - tenantFilter: currentTenant, - Endpoint: "/directoryRoles(roleTemplateId='62e90394-69f5-4237-9190-012177145e10')/members", - $select: "displayName,userPrincipalName,accountEnabled", - }, - }); - const sharepoint = ApiGetCall({ url: "/api/ListSharepointQuota", queryKey: `${currentTenant}-ListSharepointQuota`, @@ -293,11 +283,11 @@ const Page = () => { tenantId={organization.data?.id} userStats={{ licensedUsers: dashboard.data?.LicUsers || 0, - unlicensedUsers: dashboard.data?.Users && dashboard.data?.LicUsers && GlobalAdminList.data?.Results && dashboard.data?.Guests - ? dashboard.data?.Users - dashboard.data?.LicUsers - dashboard.data?.Guests - GlobalAdminList.data?.Results?.length + unlicensedUsers: dashboard.data?.Users && dashboard.data?.LicUsers + ? dashboard.data?.Users - dashboard.data?.LicUsers : 0, guests: dashboard.data?.Guests || 0, - globalAdmins: GlobalAdminList.data?.Results?.length || 0 + globalAdmins: dashboard.data?.Gas || 0 }} standardsData={driftApi.data} organizationData={organization.data} @@ -316,23 +306,17 @@ const Page = () => { diff --git a/src/pages/security/defender/deployment/index.js b/src/pages/security/defender/deployment/index.js index c429c3107e45..3ddb85e5d4cb 100644 --- a/src/pages/security/defender/deployment/index.js +++ b/src/pages/security/defender/deployment/index.js @@ -207,14 +207,60 @@ const DeployDefenderForm = () => { /> + + + + @@ -233,7 +279,7 @@ const DeployDefenderForm = () => { /> @@ -245,21 +291,21 @@ const DeployDefenderForm = () => { /> - { name="Policy.DisableCatchupQuickScan" formControl={formControl} /> + + + + + - {/* Assign to Group */} - - Assign to Group + + + {/* Threat Remediation Actions Section */} + + + Threat Remediation Actions + + + + + + + + + + + + {/* Assignment Section */} + + + Policy Assignment + + + @@ -546,8 +748,6 @@ const DeployDefenderForm = () => { - - {/* Remove the Review and Confirm section as per your request */} ); diff --git a/src/pages/security/incidents/list-check-alerts/index.js b/src/pages/security/incidents/list-check-alerts/index.js new file mode 100644 index 000000000000..6d1401518d0f --- /dev/null +++ b/src/pages/security/incidents/list-check-alerts/index.js @@ -0,0 +1,61 @@ +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; +import { Alert, Link } from "@mui/material"; + +const Page = () => { + const pageTitle = "Check Alerts"; + + // Explainer component + const explainer = ( + + This page collects the alerts from Check by Cyberdrain, a browser plugin that blocks AiTM + (Adversary-in-the-Middle) attacks. Check provides real-time protection against phishing and + credential theft attempts. Learn more at{" "} + + docs.check.tech + {" "} + or install the plugin now: + + Microsoft Edge + {" "} + | + + Chrome + + + ); + + const columns = [ + "tenantFilter", + "type", + "url", + "reason", + "score", + "threshold", + "potentialUserName", + "potentialUserDisplayName", + "reportedByIP", + "timestamp", + ]; + + return ( + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/tenant/administration/securescore/index.js b/src/pages/tenant/administration/securescore/index.js index f6169e00005b..2fc2f04346e2 100644 --- a/src/pages/tenant/administration/securescore/index.js +++ b/src/pages/tenant/administration/securescore/index.js @@ -128,6 +128,7 @@ const Page = () => { createDialog.handleOpen(); }} variant="contained" + disabled={secureScoreControl.controlName.startsWith("scid_")} > Change Status diff --git a/src/pages/tenant/administration/tenants/edit.js b/src/pages/tenant/administration/tenants/edit.js index f198c7a3aa0f..44f643c5653d 100644 --- a/src/pages/tenant/administration/tenants/edit.js +++ b/src/pages/tenant/administration/tenants/edit.js @@ -67,6 +67,7 @@ const Page = () => { RemoveMobile: false, DisableSignIn: false, RemoveMFADevices: false, + RemoveTeamsPhoneDID: false, ClearImmutableId: false, }; @@ -110,6 +111,7 @@ const Page = () => { RemoveMobile: false, DisableSignIn: false, RemoveMFADevices: false, + RemoveTeamsPhoneDID: false, ClearImmutableId: false, }; diff --git a/src/pages/tenant/administration/tenants/index.js b/src/pages/tenant/administration/tenants/index.js index f3688b658de0..129b26fb1db2 100644 --- a/src/pages/tenant/administration/tenants/index.js +++ b/src/pages/tenant/administration/tenants/index.js @@ -1,7 +1,7 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { TabbedLayout } from "/src/layouts/TabbedLayout"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; -import { Edit } from "@mui/icons-material"; +import { DeleteOutline, Edit } from "@mui/icons-material"; import tabOptions from "./tabOptions"; const Page = () => { @@ -26,9 +26,23 @@ const Page = () => { const actions = [ { label: "Edit Tenant", - link: "/tenant/administration/tenants/edit?id=[customerId]", + link: "/tenant/manage/edit?tenantFilter=[defaultDomainName]", icon: , }, + { + label: "Configure Backup", + link: "/tenant/manage/configuration-backup?tenantFilter=[defaultDomainName]", + icon: , + }, + { + label: "Delete Capabilities Cache", + type: "GET", + url: "/api/RemoveTenantCapabilitiesCache", + data: { defaultDomainName: "defaultDomainName" }, + confirmText: "Are you sure you want to delete the capabilities cache for this tenant?", + color: "info", + icon: , + }, ]; return ( diff --git a/src/pages/tenant/conditional/deploy-vacation/add.jsx b/src/pages/tenant/conditional/deploy-vacation/add.jsx index b4fa24353a71..afa926d47db2 100644 --- a/src/pages/tenant/conditional/deploy-vacation/add.jsx +++ b/src/pages/tenant/conditional/deploy-vacation/add.jsx @@ -74,6 +74,7 @@ const Page = () => { validators={{ required: "Picking a user is required" }} required={true} disabled={!tenantDomain} + showRefresh={true} /> @@ -93,8 +94,10 @@ const Page = () => { queryKey: `ListConditionalAccessPolicies-${tenantDomain}`, url: "/api/ListConditionalAccessPolicies", data: { tenantFilter: tenantDomain }, + dataKey: "Results", labelField: (option) => `${option.displayName}`, valueField: "id", + showRefresh: true, } : null } diff --git a/src/pages/tenant/conditional/deploy-vacation/index.js b/src/pages/tenant/conditional/deploy-vacation/index.js index a4a95667cfee..6d9bb2a1b88c 100644 --- a/src/pages/tenant/conditional/deploy-vacation/index.js +++ b/src/pages/tenant/conditional/deploy-vacation/index.js @@ -41,6 +41,7 @@ const Page = () => { actions={actions} simpleColumns={[ "Tenant", + "Parameters.Users.addedFields.userPrincipalName", "Name", "TaskState", "ScheduledTime", diff --git a/src/pages/tenant/conditional/list-policies/index.js b/src/pages/tenant/conditional/list-policies/index.js index be83cfaad658..1612ed5d4bda 100644 --- a/src/pages/tenant/conditional/list-policies/index.js +++ b/src/pages/tenant/conditional/list-policies/index.js @@ -1,16 +1,15 @@ import { Layout as DashboardLayout } from "/src/layouts/index.js"; import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx"; import { - Block as BlockIcon, - Check as CheckIcon, - Delete as DeleteIcon, - MenuBook as MenuBookIcon, - AddModerator as AddModeratorIcon, - Visibility as VisibilityIcon, - Edit as EditIcon, + Block, + Check, + Delete, + MenuBook, + Visibility, + Edit, + VerifiedUser, } from "@mui/icons-material"; -import { Box, Button } from "@mui/material"; -import Link from "next/link"; +import { Box } from "@mui/material"; import CippJsonView from "../../../../components/CippFormPages/CippJSONView"; import { CippCADeployDrawer } from "../../../../components/CippComponents/CippCADeployDrawer"; @@ -18,6 +17,7 @@ import { CippCADeployDrawer } from "../../../../components/CippComponents/CippCA const Page = () => { const pageTitle = "Conditional Access"; const apiUrl = "/api/ListConditionalAccessPolicies"; + const cardButtonPermissions = ["Tenant.ConditionalAccess.ReadWrite"]; // Actions configuration const actions = [ @@ -32,10 +32,36 @@ const Page = () => { return JSON.parse(data.rawjson); }, hideBulk: true, - confirmText: "Are you sure you want to create a template based on this policy?", - icon: , + confirmText: `Are you sure you want to create a template based on "[displayName]"?`, + icon: , color: "info", }, + { + label: "Change Display Name", + type: "POST", + url: "/api/EditCAPolicy", + data: { + GUID: "id", + }, + confirmText: `What do you want to change the display name of "[displayName]" to?`, + icon: , + color: "info", + hideBulk: true, + fields: [ + { + type: "textField", + name: "newDisplayName", + label: "New Display Name", + required: true, + validate: (value) => { + if (!value) { + return "Display name is required."; + } + return true; + }, + }, + ], + }, { label: "Enable policy", type: "POST", @@ -44,9 +70,9 @@ const Page = () => { GUID: "id", State: "!Enabled", }, - confirmText: "Are you sure you want to enable this policy?", + confirmText: `Are you sure you want to enable "[displayName]"?`, condition: (row) => row.state !== "enabled", - icon: , + icon: , color: "info", }, { @@ -57,9 +83,9 @@ const Page = () => { GUID: "id", State: "!Disabled", }, - confirmText: "Are you sure you want to disable this policy?", + confirmText: `Are you sure you want to disable "[displayName]"?`, condition: (row) => row.state !== "disabled", - icon: , + icon: , color: "info", }, { @@ -70,58 +96,32 @@ const Page = () => { GUID: "id", State: "!enabledForReportingButNotEnforced", }, - confirmText: "Are you sure you want to set this policy to report only?", + confirmText: `Are you sure you want to set "[displayName]" to report only?`, condition: (row) => row.state !== "enabledForReportingButNotEnforced", - icon: , + icon: , color: "info", }, { - label: "Delete policy", - type: "POST", - url: "/api/RemoveCAPolicy", - data: { - GUID: "id", - }, - confirmText: "Are you sure you want to delete this policy?", - icon: , - color: "danger", - }, - { - label: "Change Display Name", + label: "Add service provider exception to policy", type: "POST", - url: "/api/EditCAPolicy", + url: "/api/ExecCAServiceExclusion", data: { GUID: "id", }, - confirmText: "Are you sure you want to change the display name of this policy?", - icon: , - color: "info", - hideBulk: true, - fields: [ - { - type: "textField", - name: "newDisplayName", - label: "New Display Name", - required: true, - validate: (value) => { - if (!value) { - return "Display name is required."; - } - return true; - }, - }, - ], + confirmText: `Are you sure you want to add the service provider exception to "[displayName]"?`, + icon: , + color: "warning", }, { - label: "Add service provider exception to policy", + label: "Delete policy", type: "POST", - url: "/api/ExecCAServiceExclusion", + url: "/api/RemoveCAPolicy", data: { GUID: "id", }, - confirmText: "Are you sure you want to add the service provider exception to this policy?", - icon: , - color: "warning", + confirmText: `Are you sure you want to delete "[displayName]"?`, + icon: , + color: "danger", }, ]; @@ -137,6 +137,7 @@ const Page = () => { // Columns for CippTablePage const simpleColumns = [ + "Tenant", "displayName", "state", "modifiedDateTime", @@ -159,11 +160,12 @@ const Page = () => { - + > } title={pageTitle} apiUrl={apiUrl} + apiDataKey="Results" actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} @@ -171,5 +173,5 @@ const Page = () => { ); }; -Page.getLayout = (page) => {page}; +Page.getLayout = (page) => {page}; export default Page; diff --git a/src/pages/tenant/gdap-management/invites/add.js b/src/pages/tenant/gdap-management/invites/add.js index f7b09d1d0a59..22a45e334d1d 100644 --- a/src/pages/tenant/gdap-management/invites/add.js +++ b/src/pages/tenant/gdap-management/invites/add.js @@ -67,6 +67,7 @@ const Page = () => { if (!formControl.formState.isValid) return; const eachInvite = Array.from({ length: values.inviteCount }, (_, i) => ({ roleMappings: values.roleMappings.value, + Reference: values.Reference, })); addInvites.mutate({ @@ -180,7 +181,7 @@ const Page = () => { }} /> - + { required={true} /> + + + {selectedTemplate?.value && ( diff --git a/src/pages/tenant/gdap-management/invites/index.js b/src/pages/tenant/gdap-management/invites/index.js index 99dd16053bf1..ce1384c92fb5 100644 --- a/src/pages/tenant/gdap-management/invites/index.js +++ b/src/pages/tenant/gdap-management/invites/index.js @@ -5,13 +5,34 @@ import { CippTablePage } from "/src/components/CippComponents/CippTablePage.jsx" import { Button } from "@mui/material"; import { Add } from "@mui/icons-material"; import Link from "next/link"; -import { TrashIcon } from "@heroicons/react/24/outline"; +import { TrashIcon, PencilIcon } from "@heroicons/react/24/outline"; const pageTitle = "GDAP Invites"; -const simpleColumns = ["Timestamp", "RowKey", "InviteUrl", "OnboardingUrl", "RoleMappings"]; +const simpleColumns = ["Timestamp", "RowKey", "Reference", "Technician", "InviteUrl", "OnboardingUrl", "RoleMappings"]; const apiUrl = "/api/ListGDAPInvite"; const actions = [ + { + label: "Update Internal Reference", + url: "/api/ExecGDAPInvite", + type: "POST", + icon: , + confirmText: "Are you sure you want to update the internal reference for this invite?", + data: { + Action: "Update", + InviteId: "RowKey", + }, + fields: [ + { + label: "Internal Reference", + name: "Reference", + type: "textField", + required: false, + helperText: "Enter an internal reference/note for this GDAP invite (e.g., client name, ticket number).", + }, + ], + relatedQueryKeys: ["ListGDAPInvite"], + }, { label: "Delete Invite", url: "/api/ExecGDAPInvite", @@ -23,6 +44,7 @@ const actions = [ Action: "Delete", InviteId: "RowKey", }, + relatedQueryKeys: ["ListGDAPInvite"], }, ]; diff --git a/src/pages/tenant/gdap-management/onboarding/start.js b/src/pages/tenant/gdap-management/onboarding/start.js index d52982666c0c..e6ae11357c1a 100644 --- a/src/pages/tenant/gdap-management/onboarding/start.js +++ b/src/pages/tenant/gdap-management/onboarding/start.js @@ -134,13 +134,17 @@ const Page = () => { setInvalidRelationship(true); } } - const invite = currentInvites?.data?.pages?.[0]?.find( - (invite) => invite?.RowKey === formValue?.value - ); + const invite = + currentInvites?.data?.pages?.[0] && Array.isArray(currentInvites.data.pages[0]) + ? currentInvites.data.pages[0].find((invite) => invite?.RowKey === formValue?.value) + : null; - const onboarding = onboardingList.data?.pages?.[0]?.find( - (onboarding) => onboarding?.RowKey === formValue?.value - ); + const onboarding = + onboardingList.data?.pages?.[0] && Array.isArray(onboardingList.data.pages[0]) + ? onboardingList.data.pages[0].find( + (onboarding) => onboarding?.RowKey === formValue?.value + ) + : null; if (onboarding) { setCurrentOnboarding(onboarding); var stepCount = 0; @@ -247,6 +251,11 @@ const Page = () => { if (formControl.getValues("ignoreMissingRoles")) { data.ignoreMissingRoles = Boolean(formControl.getValues("ignoreMissingRoles")); } + if (formControl.getValues("standardsExcludeAllTenants")) { + data.standardsExcludeAllTenants = Boolean( + formControl.getValues("standardsExcludeAllTenants") + ); + } startOnboarding.mutate({ url: "/api/ExecOnboardTenant", @@ -271,6 +280,11 @@ const Page = () => { if (formControl.getValues("ignoreMissingRoles")) { data.IgnoreMissingRoles = Boolean(formControl.getValues("ignoreMissingRoles")); } + if (formControl.getValues("standardsExcludeAllTenants")) { + data.standardsExcludeAllTenants = Boolean( + formControl.getValues("standardsExcludeAllTenants") + ); + } startOnboarding.mutate({ url: "/api/ExecOnboardTenant", @@ -398,6 +412,13 @@ const Page = () => { /> > )} + {currentRelationship?.value && ( <> {currentRelationship?.addedFields?.accessDetails?.unifiedRoles.some( diff --git a/src/pages/tenant/gdap-management/roles/add.js b/src/pages/tenant/gdap-management/roles/add.js index 94cd506398c3..06808b7e77df 100644 --- a/src/pages/tenant/gdap-management/roles/add.js +++ b/src/pages/tenant/gdap-management/roles/add.js @@ -56,12 +56,14 @@ const Page = () => { groupName: selectedGroup.label, groupId: selectedGroup.value, roleName: selectedRole.label, - roleId: selectedRole.value, + roleDefinitionId: selectedRole.value, }; if ( advancedMappings.some( - (mapping) => mapping.groupId === newMapping.groupId && mapping.roleId === newMapping.roleId + (mapping) => + mapping.groupId === newMapping.groupId && + mapping.roleDefinitionId === newMapping.roleDefinitionId ) ) { return; @@ -75,7 +77,8 @@ const Page = () => { const handleRemoveMapping = (mappingToRemove) => { const updatedMappings = advancedMappings.filter( (mapping) => - mapping.groupId !== mappingToRemove.groupId || mapping.roleId !== mappingToRemove.roleId + mapping.groupId !== mappingToRemove.groupId || + mapping.roleDefinitionId !== mappingToRemove.roleDefinitionId ); setAdvancedMappings(updatedMappings); }; diff --git a/src/pages/tenant/standards/manage-drift/compare.js b/src/pages/tenant/manage/applied-standards.js similarity index 93% rename from src/pages/tenant/standards/manage-drift/compare.js rename to src/pages/tenant/manage/applied-standards.js index 1aafbf0651d5..f54db6eaab63 100644 --- a/src/pages/tenant/standards/manage-drift/compare.js +++ b/src/pages/tenant/manage/applied-standards.js @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo } from "react"; +import { CippAutoComplete } from "../../../components/CippComponents/CippAutocomplete"; import { Button, Card, @@ -26,15 +27,16 @@ import { Close, Search, FactCheck, + Policy, } from "@mui/icons-material"; import standards from "/src/data/standards.json"; -import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog"; +import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; import { SvgIcon } from "@mui/material"; import { useForm } from "react-hook-form"; -import { useSettings } from "../../../../hooks/use-settings"; -import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; +import { useSettings } from "../../../hooks/use-settings"; +import { ApiGetCall, ApiPostCall } from "../../../api/ApiCall"; import { useRouter } from "next/router"; -import { useDialog } from "../../../../hooks/use-dialog"; +import { useDialog } from "../../../hooks/use-dialog"; import { Grid } from "@mui/system"; import DOMPurify from "dompurify"; import { ClockIcon } from "@heroicons/react/24/outline"; @@ -170,7 +172,7 @@ const Page = () => { standardId, standardName: `Intune Template: ${ expandedTemplate.displayName || expandedTemplate.name || templateId - } (via ${templateItem['TemplateList-Tags'].value})`, + } (via ${templateItem["TemplateList-Tags"].value})`, currentTenantValue: standardObject !== undefined ? { @@ -314,7 +316,7 @@ const Page = () => { standardId, standardName: `Conditional Access Template: ${ expandedTemplate.displayName || expandedTemplate.name || templateId - } (via ${templateItem['TemplateList-Tags'].value})`, + } (via ${templateItem["TemplateList-Tags"].value})`, currentTenantValue: standardObject !== undefined ? { @@ -614,12 +616,69 @@ const Page = () => { // This represents the total "addressable" compliance (compliant + could be compliant if licensed) const combinedScore = compliancePercentage + missingLicensePercentage; + // Simple filter for all templates (no type filtering) + const templateOptions = templateDetails.data + ? templateDetails.data.map((template) => ({ + label: + template.displayName || + template.templateName || + template.name || + `Template ${template.GUID}`, + value: template.GUID, + })) + : []; + + // Find currently selected template + const selectedTemplateOption = + templateId && templateOptions.length + ? templateOptions.find((option) => option.value === templateId) || null + : null; + + // Effect to refetch APIs when templateId changes (needed for shallow routing) + useEffect(() => { + if (templateId) { + comparisonApi.refetch(); + } + }, [templateId]); + // Prepare title and subtitle for HeaderedTabbedLayout const title = templateDetails?.data?.filter((template) => template.GUID === templateId)?.[0]?.templateName || "Tenant Report"; const subtitle = [ + { + icon: , + text: ( + { + const query = { ...router.query }; + if (selectedTemplate && selectedTemplate.value) { + query.templateId = selectedTemplate.value; + } else { + delete query.templateId; + } + router.replace( + { + pathname: router.pathname, + query: query, + }, + undefined, + { shallow: true } + ); + }} + sx={{ minWidth: 300 }} + placeholder="Select a template..." + /> + ), + }, // Add compliance badges when template data is available (show even if no comparison data yet) ...(templateDetails?.data?.filter((template) => template.GUID === templateId)?.[0] ? [ @@ -996,7 +1055,7 @@ const Page = () => { sx={{ wordBreak: "break-word", overflowWrap: "break-word", - hyphens: "auto" + hyphens: "auto", }} > {standard?.standardName} @@ -1250,16 +1309,33 @@ const Page = () => { JSON.stringify(actualValue) !== JSON.stringify(standardValueForKey); + // Format the display value + let displayValue; + if (typeof value === "object" && value !== null) { + displayValue = + value?.label || JSON.stringify(value, null, 2); + } else if (value === true) { + displayValue = "Enabled"; + } else if (value === false) { + displayValue = "Disabled"; + } else { + displayValue = String(value); + } + return ( - + {key}: - {" "} + { isDifferent ? "medium" : "inherit", + wordBreak: "break-word", + overflowWrap: "break-word", + whiteSpace: "pre-wrap", + flex: 1, + minWidth: 0, + fontFamily: + typeof value === "object" && + value !== null && + !value?.label + ? "monospace" + : "inherit", + fontSize: + typeof value === "object" && + value !== null && + !value?.label + ? "0.75rem" + : "inherit", + m: 0, }} > - {typeof value === "object" && value !== null - ? value?.label || JSON.stringify(value) - : value === true - ? "Enabled" - : value === false - ? "Disabled" - : String(value)} + {displayValue} ); diff --git a/src/pages/tenant/manage/configuration-backup.js b/src/pages/tenant/manage/configuration-backup.js new file mode 100644 index 000000000000..0cf7dd569c72 --- /dev/null +++ b/src/pages/tenant/manage/configuration-backup.js @@ -0,0 +1,399 @@ +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { HeaderedTabbedLayout } from "/src/layouts/HeaderedTabbedLayout"; +import { + Button, + Box, + Typography, + Alert, + AlertTitle, + Card, + CardContent, + Stack, + Skeleton, + Chip, +} from "@mui/material"; +import { Grid } from "@mui/system"; +import { + Storage, + History, + EventRepeat, + Schedule, + SettingsBackupRestore, + Settings, + CheckCircle, + Cancel, + Delete, +} from "@mui/icons-material"; +import { useSettings } from "/src/hooks/use-settings"; +import { ApiGetCall } from "/src/api/ApiCall"; +import { CippPropertyListCard } from "/src/components/CippCards/CippPropertyListCard"; +import { CippBackupScheduleDrawer } from "/src/components/CippComponents/CippBackupScheduleDrawer"; +import { CippRestoreBackupDrawer } from "/src/components/CippComponents/CippRestoreBackupDrawer"; +import { CippApiDialog } from "/src/components/CippComponents/CippApiDialog"; +import { CippTimeAgo } from "/src/components/CippComponents/CippTimeAgo"; +import { useDialog } from "/src/hooks/use-dialog"; +import ReactTimeAgo from "react-time-ago"; +import tabOptions from "./tabOptions.json"; +import { useRouter } from "next/router"; +import { CippHead } from "/src/components/CippComponents/CippHead"; + +const Page = () => { + const router = useRouter(); + const { templateId } = router.query; + const settings = useSettings(); + const removeDialog = useDialog(); + + // API call to get backup files + const backupList = ApiGetCall({ + url: "/api/ExecListBackup", + data: { + tenantFilter: settings.currentTenant, + Type: "Scheduled", + NameOnly: true, + }, + queryKey: `BackupList-${settings.currentTenant}`, + }); + + // API call to get existing backup configuration/schedule + const existingBackupConfig = ApiGetCall({ + url: "/api/ListScheduledItems", + data: { + showHidden: true, + Type: "New-CIPPBackup", + }, + queryKey: `BackupTasks-${settings.currentTenant}`, + }); + + // Use the actual backup files as the backup data + const filteredBackupData = Array.isArray(backupList.data) ? backupList.data : []; + // Generate backup tags from actual API response items - use raw items directly + const generateBackupTags = (backup) => { + // Use the Items array directly from the API response without any translation + if (backup.Items && Array.isArray(backup.Items)) { + return backup.Items; + } + + // Fallback if no items found + return ["Configuration"]; + }; + + const backupDisplayItems = filteredBackupData.map((backup, index) => ({ + id: backup.RowKey || index, + name: backup.BackupName || "Unnamed Backup", + timestamp: backup.Timestamp, + tenantSource: backup.BackupName?.includes("AllTenants") + ? "All Tenants" + : backup.BackupName?.replace("CIPP Backup - ", "") || settings.currentTenant, + tags: generateBackupTags(backup), + })); + + // Process existing backup configuration, find tenantFilter. by comparing settings.currentTenant with Tenant.value + const currentConfig = Array.isArray(existingBackupConfig.data) + ? existingBackupConfig.data.find((tenant) => tenant.Tenant.value === settings.currentTenant) + : null; + const hasExistingConfig = currentConfig && currentConfig.Parameters?.ScheduledBackupValues; + + // Create property items for current configuration + const configPropertyItems = hasExistingConfig + ? [ + { label: "Backup Name", value: currentConfig.Name }, + { + label: "Tenant", + value: + currentConfig.Tenant?.value || + currentConfig.Tenant || + currentConfig.TenantFilter || + settings.currentTenant, + }, + { label: "Recurrence", value: currentConfig.Recurrence?.value || "Daily" }, + { label: "Task State", value: currentConfig.TaskState || "Unknown" }, + { + label: "Last Executed", + value: currentConfig.ExecutedTime ? ( + + ) : ( + "Never" + ), + }, + { + label: "Next Run", + value: currentConfig.ScheduledTime ? ( + + ) : ( + "Not scheduled" + ), + }, + ] + : []; + + // Create component status tags + const getEnabledComponents = () => { + if (!hasExistingConfig) return []; + + const values = currentConfig.Parameters.ScheduledBackupValues; + const enabledComponents = []; + + if (values.users) enabledComponents.push("Users"); + if (values.groups) enabledComponents.push("Groups"); + if (values.ca) enabledComponents.push("Conditional Access"); + if (values.intuneconfig) enabledComponents.push("Intune Configuration"); + if (values.intunecompliance) enabledComponents.push("Intune Compliance"); + if (values.intuneprotection) enabledComponents.push("Intune Protection"); + if (values.antispam) enabledComponents.push("Anti-Spam"); + if (values.antiphishing) enabledComponents.push("Anti-Phishing"); + if (values.CippWebhookAlerts) enabledComponents.push("CIPP Webhook Alerts"); + if (values.CippScriptedAlerts) enabledComponents.push("CIPP Scripted Alerts"); + if (values.CippCustomVariables) enabledComponents.push("Custom Variables"); + + return enabledComponents; + }; + + // Info bar data following CIPP patterns + const infoBarData = [ + { + icon: , + name: "Total Backups", + data: filteredBackupData?.length || 0, + }, + { + icon: , + name: "Last Backup", + data: filteredBackupData?.[0]?.Timestamp ? ( + + ) : ( + "No Backups" + ), + }, + { + icon: , + name: "Tenant Scope", + data: settings.currentTenant === "AllTenants" ? "All Tenants" : settings.currentTenant, + }, + { + icon: , + name: "Configuration", + data: hasExistingConfig ? "Configured" : "Not Configured", + }, + ]; + + const title = "Manage Backups"; + + return ( + + + + + {/* Two Side-by-Side Displays */} + + + {/* Current Configuration Header */} + + + + + + Current Configuration + + {!hasExistingConfig ? ( + { + // Refresh both queries when a backup schedule is added + setTimeout(() => { + backupList.refetch(); + existingBackupConfig.refetch(); + }, 2000); + }} + /> + ) : ( + } + color="error" + > + Remove Backup Schedule + + )} + + + + + {/* Configuration Details */} + {existingBackupConfig.isFetching ? ( + + + + + ) : hasExistingConfig ? ( + + + + + + Backup Components + + + {getEnabledComponents().map((component, idx) => ( + } + /> + ))} + {getEnabledComponents().length === 0 && ( + } + /> + )} + + + + + ) : ( + + No Backup Configuration + No backup schedule is currently configured for{" "} + {settings.currentTenant === "AllTenants" ? "any tenant" : settings.currentTenant}. + Click "Add Backup Schedule" to create an automated backup configuration. + + )} + + + + + {/* Backup History */} + + + + + + Backup History + + + + {settings.currentTenant === "AllTenants" + ? "Viewing backups for all tenants." + : `Viewing backups for ${settings.currentTenant} and global backups.`} + + + {filteredBackupData.length === 0 && !backupList.isFetching ? ( + + No Backup History + {settings.currentTenant === "AllTenants" + ? "No backups exist for any tenant." + : `No backups found for ${settings.currentTenant}.`} + + ) : backupList.isFetching ? ( + + + + ) : ( + + + {backupDisplayItems.map((backup) => ( + + + + + + + {(() => { + const match = backup.name.match( + /.*_(\d{4}-\d{2}-\d{2})-(\d{2})(\d{2})/ + ); + return match + ? `${match[1]} @ ${match[2]}:${match[3]}` + : backup.name; + })()} + + + + + + + } + /> + + + + {backup.tags.map((tag, idx) => ( + + ))} + + + + + ))} + + + )} + + + + + + + + {/* Remove Backup Schedule Dialog */} + { + // Refresh both queries when a backup schedule is removed + setTimeout(() => { + backupList.refetch(); + existingBackupConfig.refetch(); + }, 2000); + }} + /> + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/tenant/standards/manage-drift/index.js b/src/pages/tenant/manage/drift.js similarity index 94% rename from src/pages/tenant/standards/manage-drift/index.js rename to src/pages/tenant/manage/drift.js index 76c39257cea9..dd2b59c340be 100644 --- a/src/pages/tenant/standards/manage-drift/index.js +++ b/src/pages/tenant/manage/drift.js @@ -28,6 +28,7 @@ import tabOptions from "./tabOptions.json"; import standardsData from "/src/data/standards.json"; import { createDriftManagementActions } from "./driftManagementActions"; import { ExecutiveReportButton } from "/src/components/ExecutiveReportButton"; +import { CippAutoComplete } from "../../../components/CippComponents/CippAutocomplete"; const ManageDriftPage = () => { const router = useRouter(); @@ -532,6 +533,13 @@ const ManageDriftPage = () => { } }, [triggerReport]); + // Effect to refetch APIs when templateId changes (needed for shallow routing) + useEffect(() => { + if (templateId) { + comparisonApi.refetch(); + } + }, [templateId]); + // Add action buttons to each deviation item const deviationItemsWithActions = deviationItems.map((item) => { // Check if this is a template that supports delete action @@ -712,11 +720,53 @@ const ManageDriftPage = () => { const missingLicensePercentage = 0; // This would need to be calculated from actual license data const combinedScore = compliancePercentage + missingLicensePercentage; + // Simple filter for drift templates + const driftTemplateOptions = standardsApi.data + ? standardsApi.data + .filter((template) => template.type === "drift" || template.Type === "drift") + .map((template) => ({ + label: template.displayName || template.templateName || template.name || `Template ${template.GUID}`, + value: template.GUID, + })) + : []; + + // Find currently selected template + const selectedTemplateOption = templateId && driftTemplateOptions.length + ? driftTemplateOptions.find((option) => option.value === templateId) || null + : null; const title = "Manage Drift"; const subtitle = [ { icon: , - text: `Template ID: ${templateId || "Loading..."}`, + text: ( + { + const query = { ...router.query }; + if (selectedTemplate && selectedTemplate.value) { + query.templateId = selectedTemplate.value; + } else { + delete query.templateId; + } + router.replace( + { + pathname: router.pathname, + query: query, + }, + undefined, + { shallow: true } + ); + }} + sx={{ minWidth: 300 }} + placeholder="Select a drift template..." + /> + ), }, // Add compliance badges when data is available ...(totalPolicies > 0 diff --git a/src/pages/tenant/standards/manage-drift/driftManagementActions.js b/src/pages/tenant/manage/driftManagementActions.js similarity index 100% rename from src/pages/tenant/standards/manage-drift/driftManagementActions.js rename to src/pages/tenant/manage/driftManagementActions.js diff --git a/src/pages/tenant/manage/edit.js b/src/pages/tenant/manage/edit.js new file mode 100644 index 000000000000..a49d0afe00b8 --- /dev/null +++ b/src/pages/tenant/manage/edit.js @@ -0,0 +1,342 @@ +import { Layout as DashboardLayout } from "/src/layouts/index.js"; +import { HeaderedTabbedLayout } from "/src/layouts/HeaderedTabbedLayout"; +import { useForm, useFormState } from "react-hook-form"; +import { ApiGetCall, ApiPostCall } from "/src/api/ApiCall"; +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import CippFormComponent from "/src/components/CippComponents/CippFormComponent"; +import { Stack, Box, Typography, Button, Card, CardContent } from "@mui/material"; +import { Grid } from "@mui/system"; +import { CippPropertyListCard } from "/src/components/CippCards/CippPropertyListCard"; +import CippButtonCard from "/src/components/CippCards/CippButtonCard"; +import { getCippFormatting } from "/src/utils/get-cipp-formatting"; +import CippCustomVariables from "/src/components/CippComponents/CippCustomVariables"; +import { CippOffboardingDefaultSettings } from "/src/components/CippComponents/CippOffboardingDefaultSettings"; +import { CippApiResults } from "/src/components/CippComponents/CippApiResults"; +import { useSettings } from "/src/hooks/use-settings"; +import { Business, Save } from "@mui/icons-material"; +import tabOptions from "./tabOptions.json"; +import { CippHead } from "/src/components/CippComponents/CippHead"; + +const Page = () => { + const router = useRouter(); + const { templateId } = router.query; + const settings = useSettings(); + const currentTenant = settings.currentTenant; + + const formControl = useForm({ + mode: "onChange", + }); + + const offboardingFormControl = useForm({ + mode: "onChange", + }); + + // API call for updating tenant properties + const updateTenant = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [ + `TenantProperties_${currentTenant}`, + "ListTenants-notAllTenants", + "TenantSelector", + ], + }); + + // API call for updating offboarding defaults + const updateOffboardingDefaults = ApiPostCall({ + urlFromData: true, + relatedQueryKeys: [`TenantProperties_${currentTenant}`, "CustomVariables*"], + }); + + const { isValid: isFormValid } = useFormState({ control: formControl.control }); + const { isValid: isOffboardingFormValid } = useFormState({ + control: offboardingFormControl.control, + }); + + const tenantDetails = ApiGetCall({ + url: + currentTenant && currentTenant !== "AllTenants" + ? `/api/ListTenantDetails?tenantFilter=${currentTenant}` + : null, + queryKey: + currentTenant && currentTenant !== "AllTenants" ? `TenantProperties_${currentTenant}` : null, + }); + + useEffect(() => { + if (tenantDetails.isSuccess && tenantDetails.data && currentTenant !== "AllTenants") { + formControl.reset({ + customerId: currentTenant, + Alias: tenantDetails?.data?.customProperties?.Alias ?? "", + Groups: + tenantDetails.data.Groups?.map((group) => ({ + label: group.Name, + value: group.Id, + })) || [], + }); + + // Set up offboarding defaults with default values + const tenantOffboardingDefaults = tenantDetails.data?.customProperties?.OffboardingDefaults; + const defaultOffboardingValues = { + ConvertToShared: false, + RemoveGroups: false, + HideFromGAL: false, + RemoveLicenses: false, + removeCalendarInvites: false, + RevokeSessions: false, + removePermissions: false, + RemoveRules: false, + ResetPass: false, + KeepCopy: false, + DeleteUser: false, + RemoveMobile: false, + DisableSignIn: false, + RemoveMFADevices: false, + RemoveTeamsPhoneDID: false, + ClearImmutableId: false, + }; + + let offboardingDefaults = {}; + + if (tenantOffboardingDefaults) { + try { + const parsed = JSON.parse(tenantOffboardingDefaults); + offboardingDefaults = { + offboardingDefaults: { ...defaultOffboardingValues, ...parsed }, + }; + } catch { + offboardingDefaults = { offboardingDefaults: defaultOffboardingValues }; + } + } else { + offboardingDefaults = { offboardingDefaults: defaultOffboardingValues }; + } + + offboardingFormControl.reset(offboardingDefaults); + } + }, [tenantDetails.isSuccess, tenantDetails.data, currentTenant]); + + const handleResetOffboardingDefaults = () => { + const defaultOffboardingValues = { + ConvertToShared: false, + RemoveGroups: false, + HideFromGAL: false, + RemoveLicenses: false, + removeCalendarInvites: false, + RevokeSessions: false, + removePermissions: false, + RemoveRules: false, + ResetPass: false, + KeepCopy: false, + DeleteUser: false, + RemoveMobile: false, + DisableSignIn: false, + RemoveMFADevices: false, + RemoveTeamsPhoneDID: false, + ClearImmutableId: false, + }; + + offboardingFormControl.reset({ offboardingDefaults: defaultOffboardingValues }); + }; + + const title = "Manage Tenant"; + + // Show message for AllTenants + if (currentTenant === "AllTenants") { + return ( + + + + + + + + + Select a Specific Tenant + + + Tenant editing is not available when "All Tenants" is selected. Please select a + specific tenant to edit its configuration. + + + + + + + ); + } + + return ( + + + + + {/* First Row - Tenant Details and Edit Form */} + + + + + + } + onClick={formControl.handleSubmit((values) => { + const formattedValues = { + tenantAlias: values.Alias, + tenantGroups: values.Groups.map((group) => ({ + groupId: group.value, + groupName: group.label, + })), + customerId: tenantDetails.data?.id, + }; + updateTenant.mutate({ + url: "/api/EditTenant", + data: formattedValues, + }); + })} + disabled={updateTenant.isPending || !isFormValid || tenantDetails.isFetching} + > + {updateTenant.isPending ? "Saving..." : "Save Changes"} + + } + isFetching={tenantDetails.isFetching} + > + + + + + + + + + {/* Second Row - Offboarding Defaults and Custom Variables */} + + } + onClick={offboardingFormControl.handleSubmit((values) => { + const offboardingSettings = values.offboardingDefaults || values; + const formattedValues = { + customerId: currentTenant, + offboardingDefaults: offboardingSettings, + }; + updateOffboardingDefaults.mutate({ + url: "/api/EditTenantOffboardingDefaults", + data: formattedValues, + }); + })} + disabled={ + updateOffboardingDefaults.isPending || + !isOffboardingFormValid || + tenantDetails.isFetching + } + > + {updateOffboardingDefaults.isPending ? "Saving..." : "Save Changes"} + + } + isFetching={tenantDetails.isFetching} + > + + + Configure default offboarding settings specifically for this tenant. These + settings will override user defaults when offboarding users in this tenant. + + + + + + + Reset All to Off + + + Click "Reset All to Off" to turn off all options, then click "Save" to clear + tenant defaults. + + + + + + + + + + + + + Custom Variables + + + + + + + + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/pages/tenant/standards/manage-drift/history.js b/src/pages/tenant/manage/history.js similarity index 97% rename from src/pages/tenant/standards/manage-drift/history.js rename to src/pages/tenant/manage/history.js index 0aa7d9b837b0..5a3dd89eba72 100644 --- a/src/pages/tenant/standards/manage-drift/history.js +++ b/src/pages/tenant/manage/history.js @@ -33,7 +33,7 @@ import { ExpandMore, } from "@mui/icons-material"; import tabOptions from "./tabOptions.json"; -import { useSettings } from "../../../../hooks/use-settings"; +import { useSettings } from "../../../hooks/use-settings"; import { createDriftManagementActions } from "./driftManagementActions"; const Page = () => { @@ -133,14 +133,7 @@ const Page = () => { currentTenant: tenant, }); - const title = "Manage Drift"; - const subtitle = [ - { - icon: , - text: `Template ID: ${templateId || "Loading..."}`, - }, - ]; - + const title = "View History"; // Sort logs by date (newest first) const sortedLogs = logsData.data ? [...logsData.data].sort((a, b) => new Date(b.DateTime) - new Date(a.DateTime)) @@ -150,7 +143,6 @@ const Page = () => { { const userSettingsDefaults = useSettings(); @@ -71,7 +73,7 @@ const PoliciesDeployedPage = () => { return "Deployed"; } else { // Check if there's drift data for this standard to get the deviation status - const driftData = driftApi.data || []; + const driftData = Array.isArray(driftApi.data) ? driftApi.data : []; // For templates, we need to match against the full template path let searchKeys = [standardKey, `standards.${standardKey}`]; @@ -105,7 +107,7 @@ const PoliciesDeployedPage = () => { // Helper function to get display name from drift data const getDisplayNameFromDrift = (standardKey, templateValue = null, templateType = null) => { - const driftData = driftApi.data || []; + const driftData = Array.isArray(driftApi.data) ? driftApi.data : []; // For templates, we need to match against the full template path let searchKeys = [standardKey, `standards.${standardKey}`]; @@ -323,6 +325,32 @@ const PoliciesDeployedPage = () => { }); } }); + // Simple filter for all templates (no type filtering) + const templateOptions = standardsApi.data + ? standardsApi.data.map((template) => ({ + label: + template.displayName || + template.templateName || + template.name || + `Template ${template.GUID}`, + value: template.GUID, + })) + : []; + + // Find currently selected template + const selectedTemplateOption = + templateId && templateOptions.length + ? templateOptions.find((option) => option.value === templateId) || null + : null; + + // Effect to refetch APIs when templateId changes (needed for shallow routing) + useEffect(() => { + if (templateId) { + comparisonApi.refetch(); + driftApi.refetch(); + } + }, [templateId]); + const actions = createDriftManagementActions({ templateId, onRefresh: () => { @@ -332,11 +360,39 @@ const PoliciesDeployedPage = () => { }, currentTenant, }); - const title = "Manage Drift"; + const title = "View Deployed Policies"; const subtitle = [ { icon: , - text: `Template ID: ${templateId || "Loading..."}`, + text: ( + { + const query = { ...router.query }; + if (selectedTemplate && selectedTemplate.value) { + query.templateId = selectedTemplate.value; + } else { + delete query.templateId; + } + router.replace( + { + pathname: router.pathname, + query: query, + }, + undefined, + { shallow: true } + ); + }} + sx={{ minWidth: 300 }} + placeholder="Select a template..." + /> + ), }, ]; diff --git a/src/pages/tenant/standards/manage-drift/recover-policies.js b/src/pages/tenant/manage/recover-policies.js similarity index 100% rename from src/pages/tenant/standards/manage-drift/recover-policies.js rename to src/pages/tenant/manage/recover-policies.js diff --git a/src/pages/tenant/manage/tabOptions.json b/src/pages/tenant/manage/tabOptions.json new file mode 100644 index 000000000000..681c61cad32e --- /dev/null +++ b/src/pages/tenant/manage/tabOptions.json @@ -0,0 +1,26 @@ +[ + { + "label": "Edit Tenant", + "path": "/tenant/manage/edit" + }, + { + "label": "Manage Drift", + "path": "/tenant/manage/drift" + }, + { + "label": "Configuration Backup", + "path": "/tenant/manage/configuration-backup" + }, + { + "label": "Applied Standards Report", + "path": "/tenant/manage/applied-standards" + }, + { + "label": "Policies and Settings Deployed", + "path": "/tenant/manage/policies-deployed" + }, + { + "label": "History", + "path": "/tenant/manage/history" + } +] \ No newline at end of file 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 aa5f940a561a..a204fddb81b9 100644 --- a/src/pages/tenant/standards/list-standards/classic-standards/index.js +++ b/src/pages/tenant/standards/list-standards/classic-standards/index.js @@ -10,6 +10,8 @@ import { CippApiResults } from "../../../../../components/CippComponents/CippApi import { EyeIcon } from "@heroicons/react/24/outline"; import tabOptions from "../tabOptions.json"; import { useSettings } from "/src/hooks/use-settings.js"; +import { CippPolicyImportDrawer } from "../../../../../components/CippComponents/CippPolicyImportDrawer.jsx"; +import { PermissionButton } from "/src/utils/permissions.js"; const Page = () => { const oldStandards = ApiGetCall({ url: "/api/ListStandards", queryKey: "ListStandards-legacy" }); @@ -22,10 +24,11 @@ const Page = () => { const currentTenant = useSettings().currentTenant; const pageTitle = "Templates"; + const cardButtonPermissions = ["Tenant.Standards.ReadWrite"]; const actions = [ { label: "View Tenant Report", - link: "/tenant/standards/manage-drift/compare?templateId=[GUID]", + link: "/tenant/manage/applied-standards/?templateId=[GUID]", icon: , color: "info", target: "_self", @@ -183,9 +186,20 @@ const Page = () => { } sx={{ mr: 1 }}> Add Template - }> + } + sx={{ mr: 1 }} + > Create Drift Template + > } actions={actions} diff --git a/src/pages/tenant/standards/list-standards/drift-alignment/index.js b/src/pages/tenant/standards/list-standards/drift-alignment/index.js index 561a94daaeb6..55c408abf622 100644 --- a/src/pages/tenant/standards/list-standards/drift-alignment/index.js +++ b/src/pages/tenant/standards/list-standards/drift-alignment/index.js @@ -10,7 +10,7 @@ const Page = () => { const actions = [ { label: "View Tenant Report", - link: "/tenant/standards/compare?tenantFilter=[tenantFilter]&templateId=[standardId]", + link: "/tenant/manage/applied-standards/?tenantFilter=[tenantFilter]&templateId=[standardId]", icon: , color: "info", target: "_self", @@ -41,4 +41,4 @@ Page.getLayout = (page) => ( ); -export default Page; \ No newline at end of file +export default Page; diff --git a/src/pages/tenant/standards/list-standards/index.js b/src/pages/tenant/standards/list-standards/index.js index c4c95550bd02..28ad3abd2f4f 100644 --- a/src/pages/tenant/standards/list-standards/index.js +++ b/src/pages/tenant/standards/list-standards/index.js @@ -13,14 +13,14 @@ const Page = () => { const actions = [ { label: "View Tenant Report", - link: "/tenant/standards/manage-drift/compare?tenantFilter=[tenantFilter]&templateId=[standardId]", + link: "/tenant/manage/applied-standards/?tenantFilter=[tenantFilter]&templateId=[standardId]", icon: , color: "info", target: "_self", }, { label: "Manage Drift", - link: "/tenant/standards/manage-drift?templateId=[standardId]&tenantFilter=[tenantFilter]", + link: "/tenant/manage/drift?templateId=[standardId]&tenantFilter=[tenantFilter]", icon: , color: "info", target: "_self", diff --git a/src/pages/tenant/standards/manage-drift/tabOptions.json b/src/pages/tenant/standards/manage-drift/tabOptions.json deleted file mode 100644 index 50b3adfd16dc..000000000000 --- a/src/pages/tenant/standards/manage-drift/tabOptions.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "label": "Manage Drift", - "path": "/tenant/standards/manage-drift" - }, - { - "label": "Policies and Settings Deployed", - "path": "/tenant/standards/manage-drift/policies-deployed" - }, - { - "label": "History", - "path": "/tenant/standards/manage-drift/history" - }, - { - "label": "Tenant Report", - "path": "/tenant/standards/manage-drift/compare" - } -] diff --git a/src/pages/tenant/standards/template.jsx b/src/pages/tenant/standards/template.jsx index 78f0bf71b428..357b5d7edbab 100644 --- a/src/pages/tenant/standards/template.jsx +++ b/src/pages/tenant/standards/template.jsx @@ -16,7 +16,7 @@ import { ArrowLeftIcon } from "@mui/x-date-pickers"; import { useDialog } from "../../../hooks/use-dialog"; import { ApiGetCall } from "../../../api/ApiCall"; import _ from "lodash"; -import { createDriftManagementActions } from "./manage-drift/driftManagementActions"; +import { createDriftManagementActions } from "../manage/driftManagementActions"; import { ActionsMenu } from "/src/components/actions-menu"; import { useSettings } from "/src/hooks/use-settings"; diff --git a/src/pages/tenant/standards/tenant-alignment/index.js b/src/pages/tenant/standards/tenant-alignment/index.js index 34467ff6ea4b..e891f2a0576d 100644 --- a/src/pages/tenant/standards/tenant-alignment/index.js +++ b/src/pages/tenant/standards/tenant-alignment/index.js @@ -8,7 +8,7 @@ const Page = () => { const actions = [ { label: "View Tenant Report", - link: "/tenant/standards/compare?tenantFilter=[tenantFilter]&templateId=[standardId]", + link: "/tenant/manage/applied-standards/?tenantFilter=[tenantFilter]&templateId=[standardId]", icon: , color: "info", target: "_self", diff --git a/src/utils/get-cipp-filter-variant.js b/src/utils/get-cipp-filter-variant.js index e60d933636e5..eb668bdbeee5 100644 --- a/src/utils/get-cipp-filter-variant.js +++ b/src/utils/get-cipp-filter-variant.js @@ -40,8 +40,6 @@ export const getCippFilterVariant = (providedColumnKeys, arg) => { //First key based filters switch (tailKey) { case "assignedLicenses": - console.log("Assigned Licenses Filter", sampleValue, values); - // Extract unique licenses from the data if available let filterSelectOptions = []; if (isOptions && arg.dataArray && Array.isArray(arg.dataArray)) { @@ -64,14 +62,14 @@ export const getCippFilterVariant = (providedColumnKeys, arg) => { return false; } const userSkuIds = userLicenses.map((license) => license.skuId).filter(Boolean); - return filterValue.every((selectedSkuId) => userSkuIds.includes(selectedSkuId)); + return filterValue.some((selectedSkuId) => userSkuIds.includes(selectedSkuId)); }, filterSelectOptions: filterSelectOptions, }; case "accountEnabled": return { filterVariant: "select", - sortingFn: "boolean", + sortingFn: "alphanumeric", filterFn: "equals", }; case "primDomain": @@ -117,4 +115,11 @@ export const getCippFilterVariant = (providedColumnKeys, arg) => { filterFn: "betweenInclusive", }; } + + // Default fallback for any remaining cases - use text filter to avoid localeCompare issues + return { + filterVariant: "text", + sortingFn: "alphanumeric", + filterFn: "includes", + }; }; diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 26b388d7fc8f..90902925e4d0 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -193,8 +193,11 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr // Handle hardware hash fields const hardwareHashFields = ["hardwareHash", "Hardware Hash"]; - if (hardwareHashFields.includes(cellName) || cellNameLower.includes("hardware")) { - if (typeof data === "string" && data.length > 15) { + if ( + typeof data === "string" && + (hardwareHashFields.includes(cellName) || cellNameLower.includes("hardware")) + ) { + if (data.length > 15) { return isText ? data : `${data.substring(0, 15)}...`; } return isText ? data : data; @@ -269,10 +272,6 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr return isText ? `${data}%` : ; } - if (cellName === "DMARCPercentagePass") { - return isText ? `${data}%` : ; - } - if (cellName === "ScoreExplanation") { return isText ? data : ; }
This is a placeholder page for the deploy transport rule section.
{JSON.stringify(formControl.watch(), null, 2)}