From 2cf08134bbd7e1d816505eb52169b6624a70be65 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Mon, 16 Mar 2026 00:52:15 +0100 Subject: [PATCH 01/35] Show warning when TAP is not enabled in tenant in jit-admin add --- .../identity/administration/jit-admin/add.jsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/pages/identity/administration/jit-admin/add.jsx b/src/pages/identity/administration/jit-admin/add.jsx index 9deef7e51b1b..7713d193c8cc 100644 --- a/src/pages/identity/administration/jit-admin/add.jsx +++ b/src/pages/identity/administration/jit-admin/add.jsx @@ -28,6 +28,20 @@ const Page = () => { }); const watcher = useWatch({ control: formControl.control }); + const useTAP = useWatch({ control: formControl.control, name: "UseTAP" }); + + const tapPolicy = ApiGetCall({ + url: selectedTenant + ? `/api/ListGraphRequest` + : undefined, + data: { + Endpoint: "policies/authenticationMethodsPolicy/authenticationMethodConfigurations/TemporaryAccessPass", + tenantFilter: selectedTenant?.value, + }, + queryKey: selectedTenant ? `TAPPolicy-${selectedTenant.value}` : "TAPPolicy", + waiting: !!selectedTenant, + }); + const tapEnabled = tapPolicy.isSuccess && tapPolicy.data?.Results?.[0]?.state === "enabled"; // Simple duration parser for basic ISO 8601 durations const parseDuration = (duration) => { @@ -386,6 +400,11 @@ const Page = () => { name="UseTAP" formControl={formControl} /> + {useTAP && tapPolicy.isSuccess && !tapEnabled && ( + + TAP is not enabled in this tenant. TAP generation will fail. + + )} Date: Mon, 16 Mar 2026 19:51:08 +0100 Subject: [PATCH 02/35] feat(ooo): add calendar options UI for block calendar, decline invitations, cancel meetings Expose 3 new OOO calendar checkboxes across all OOO entry points: - "Block my calendar for this period" (CreateOOFEvent + OOFEventSubject) - "Automatically decline new invitations" (AutoDeclineFutureRequestsWhenOOF) - "Decline and cancel my meetings" (DeclineEventsForScheduledOOF + DeclineMeetingMessage) Updated components: - CippUserActions OutOfOfficeForm: calendar options gated on Scheduled mode - CippExchangeSettingsForm: same options with ooo.* field prefix, extended oooFields array for form reset preservation - CippWizardVacationActions: calendar options always visible (vacation is always scheduled), pre-populates from existing user config via Get API - CippWizardVacationConfirmation: maps wizard field names to API payload, adds summary chips for enabled calendar options - vacation-mode/add/index.js: new defaults in initialState --- .../CippComponents/CippUserActions.jsx | 59 +++++++++++++- .../CippExchangeSettingsForm.jsx | 72 +++++++++++++++++ .../CippWizard/CippWizardVacationActions.jsx | 80 +++++++++++++++++++ .../CippWizardVacationConfirmation.jsx | 51 +++++++++--- .../administration/vacation-mode/add/index.js | 5 ++ 5 files changed, 256 insertions(+), 11 deletions(-) diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index 0b365b506c77..94b664d1be84 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -23,8 +23,9 @@ import { import { getCippLicenseTranslation } from "../../utils/get-cipp-license-translation"; import { useSettings } from "../../hooks/use-settings.js"; import { usePermissions } from "../../hooks/use-permissions"; -import { Tooltip, Box } from "@mui/material"; +import { Tooltip, Box, Divider, Typography } from "@mui/material"; import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; import { useWatch } from "react-hook-form"; // Separate component for Manage Licenses form to avoid hook issues @@ -257,6 +258,62 @@ const OutOfOfficeForm = ({ formControl }) => { multiline rows={4} /> + + {!areDateFieldsDisabled && ( + <> + + Calendar Options + + + + + + + + + + + + + + )} ); }; diff --git a/src/components/CippFormPages/CippExchangeSettingsForm.jsx b/src/components/CippFormPages/CippExchangeSettingsForm.jsx index ee8bfc143074..ee44517b8c51 100644 --- a/src/components/CippFormPages/CippExchangeSettingsForm.jsx +++ b/src/components/CippFormPages/CippExchangeSettingsForm.jsx @@ -14,6 +14,7 @@ import { } from "@mui/material"; import { Check, Error, Sync } from "@mui/icons-material"; import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippFormCondition } from "../CippComponents/CippFormCondition"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; import { Grid } from "@mui/system"; @@ -81,6 +82,11 @@ const CippExchangeSettingsForm = (props) => { "ExternalMessage", "StartTime", "EndTime", + "CreateOOFEvent", + "OOFEventSubject", + "AutoDeclineFutureRequestsWhenOOF", + "DeclineEventsForScheduledOOF", + "DeclineMeetingMessage", ]; // Reset the form @@ -266,6 +272,72 @@ const CippExchangeSettingsForm = (props) => { rows={4} /> + {!areDateFieldsDisabled && ( + <> + + + + Calendar Options + + + + + + + + + + + + + + + + + + + + + + + )} diff --git a/src/components/CippWizard/CippWizardVacationActions.jsx b/src/components/CippWizard/CippWizardVacationActions.jsx index c7376a1528da..1d8ac1611af7 100644 --- a/src/components/CippWizard/CippWizardVacationActions.jsx +++ b/src/components/CippWizard/CippWizardVacationActions.jsx @@ -42,6 +42,22 @@ export const CippWizardVacationActions = (props) => { if (!currentExternal) { formControl.setValue("oooExternalMessage", oooData.data.ExternalMessage || ""); } + // Pre-populate calendar options from existing config + if (oooData.data.CreateOOFEvent != null) { + formControl.setValue("oooCreateOOFEvent", !!oooData.data.CreateOOFEvent); + } + if (oooData.data.OOFEventSubject) { + formControl.setValue("oooOOFEventSubject", oooData.data.OOFEventSubject); + } + if (oooData.data.AutoDeclineFutureRequestsWhenOOF != null) { + formControl.setValue("oooAutoDeclineFutureRequests", !!oooData.data.AutoDeclineFutureRequestsWhenOOF); + } + if (oooData.data.DeclineEventsForScheduledOOF != null) { + formControl.setValue("oooDeclineEvents", !!oooData.data.DeclineEventsForScheduledOOF); + } + if (oooData.data.DeclineMeetingMessage) { + formControl.setValue("oooDeclineMeetingMessage", oooData.data.DeclineMeetingMessage); + } } }, [oooData.isSuccess, oooData.data]); @@ -359,6 +375,70 @@ export const CippWizardVacationActions = (props) => { /> )} + + {/* Calendar Options */} + + + + Calendar Options + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/CippWizard/CippWizardVacationConfirmation.jsx b/src/components/CippWizard/CippWizardVacationConfirmation.jsx index da86c414feb0..70a55423a6fb 100644 --- a/src/components/CippWizard/CippWizardVacationConfirmation.jsx +++ b/src/components/CippWizard/CippWizardVacationConfirmation.jsx @@ -58,18 +58,31 @@ export const CippWizardVacationConfirmation = (props) => { } if (values.enableOOO) { + const oooData = { + tenantFilter, + Users: values.Users, + internalMessage: values.oooInternalMessage, + externalMessage: values.oooExternalMessage, + startDate: values.startDate, + endDate: values.endDate, + reference: values.reference || null, + postExecution: values.postExecution || [], + }; + // Calendar options — only include when truthy + if (values.oooCreateOOFEvent) { + oooData.CreateOOFEvent = true; + if (values.oooOOFEventSubject) oooData.OOFEventSubject = values.oooOOFEventSubject; + } + if (values.oooAutoDeclineFutureRequests) { + oooData.AutoDeclineFutureRequestsWhenOOF = true; + } + if (values.oooDeclineEvents) { + oooData.DeclineEventsForScheduledOOF = true; + if (values.oooDeclineMeetingMessage) oooData.DeclineMeetingMessage = values.oooDeclineMeetingMessage; + } oooVacation.mutate({ url: "/api/ExecScheduleOOOVacation", - data: { - tenantFilter, - Users: values.Users, - internalMessage: values.oooInternalMessage, - externalMessage: values.oooExternalMessage, - startDate: values.startDate, - endDate: values.endDate, - reference: values.reference || null, - postExecution: values.postExecution || [], - }, + data: oooData, }); } }; @@ -244,6 +257,24 @@ export const CippWizardVacationConfirmation = (props) => { )} + {(values.oooCreateOOFEvent || values.oooAutoDeclineFutureRequests || values.oooDeclineEvents) && ( +
+ + Calendar Options + + + {values.oooCreateOOFEvent && ( + + )} + {values.oooAutoDeclineFutureRequests && ( + + )} + {values.oooDeclineEvents && ( + + )} + +
+ )} diff --git a/src/pages/identity/administration/vacation-mode/add/index.js b/src/pages/identity/administration/vacation-mode/add/index.js index eb4f73a75cbc..3730cb354c57 100644 --- a/src/pages/identity/administration/vacation-mode/add/index.js +++ b/src/pages/identity/administration/vacation-mode/add/index.js @@ -79,6 +79,11 @@ const Page = () => { enableOOO: false, oooInternalMessage: null, oooExternalMessage: null, + oooCreateOOFEvent: false, + oooOOFEventSubject: "", + oooAutoDeclineFutureRequests: false, + oooDeclineEvents: false, + oooDeclineMeetingMessage: "", startDate: null, endDate: null, postExecution: [], From a69b618eb82a3497ff4dfd85f3bb1b816f8b5887 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:56:47 +0100 Subject: [PATCH 03/35] feat: refactor assignment fields and custom data formatter Fixes #5589 --- src/pages/endpoint/applications/list/index.js | 180 ++++++------------ 1 file changed, 62 insertions(+), 118 deletions(-) diff --git a/src/pages/endpoint/applications/list/index.js b/src/pages/endpoint/applications/list/index.js index 49e5bc4b2816..87ef0d12901a 100644 --- a/src/pages/endpoint/applications/list/index.js +++ b/src/pages/endpoint/applications/list/index.js @@ -62,47 +62,59 @@ const Page = () => { }, ]; + // Builds a customDataformatter that handles both single-row and bulk (array) inputs. + const makeAssignFormatter = (getRowData) => (row, action, formData) => { + const formatRow = (singleRow) => { + const tenantFilterValue = + tenant === "AllTenants" && singleRow?.Tenant ? singleRow.Tenant : tenant; + return { + tenantFilter: tenantFilterValue, + ID: singleRow?.id, + AppType: getAppAssignmentSettingsType(singleRow?.["@odata.type"]), + AssignmentFilterName: formData?.assignmentFilter?.value || null, + AssignmentFilterType: formData?.assignmentFilter?.value + ? formData?.assignmentFilterType || "include" + : null, + ...getRowData(singleRow, formData), + }; + }; + return Array.isArray(row) ? row.map(formatRow) : formatRow(row); + }; + + const assignmentFields = [ + { + type: "radio", + name: "Intent", + label: "Assignment intent", + options: assignmentIntentOptions, + defaultValue: "Required", + validators: { required: "Select an assignment intent" }, + helperText: + "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", + }, + { + type: "radio", + name: "assignmentMode", + label: "Assignment mode", + options: assignmentModeOptions, + defaultValue: "replace", + helperText: + "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", + }, + ...getAssignmentFilterFields(), + ]; + const actions = [ { label: "Assign to All Users", type: "POST", url: "/api/ExecAssignApp", - fields: [ - { - type: "radio", - name: "Intent", - label: "Assignment intent", - options: assignmentIntentOptions, - defaultValue: "Required", - validators: { required: "Select an assignment intent" }, - helperText: - "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", - }, - { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", - options: assignmentModeOptions, - defaultValue: "replace", - helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", - }, - ...getAssignmentFilterFields(), - ], - customDataformatter: (row, action, formData) => { - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; - return { - tenantFilter: tenantFilterValue, - ID: row?.id, - AssignTo: "AllUsers", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", - AssignmentFilterName: formData?.assignmentFilter?.value || null, - AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" - : null, - }; - }, + fields: assignmentFields, + customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ + AssignTo: "AllUsers", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + })), confirmText: 'Are you sure you want to assign "[displayName]" to all users?', icon: , color: "info", @@ -111,42 +123,12 @@ const Page = () => { label: "Assign to All Devices", type: "POST", url: "/api/ExecAssignApp", - fields: [ - { - type: "radio", - name: "Intent", - label: "Assignment intent", - options: assignmentIntentOptions, - defaultValue: "Required", - validators: { required: "Select an assignment intent" }, - helperText: - "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", - }, - { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", - options: assignmentModeOptions, - defaultValue: "replace", - helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", - }, - ...getAssignmentFilterFields(), - ], - customDataformatter: (row, action, formData) => { - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; - return { - tenantFilter: tenantFilterValue, - ID: row?.id, - AssignTo: "AllDevices", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", - AssignmentFilterName: formData?.assignmentFilter?.value || null, - AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" - : null, - }; - }, + fields: assignmentFields, + customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ + AssignTo: "AllDevices", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + })), confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', icon: , color: "info", @@ -155,42 +137,12 @@ const Page = () => { label: "Assign Globally (All Users / All Devices)", type: "POST", url: "/api/ExecAssignApp", - fields: [ - { - type: "radio", - name: "Intent", - label: "Assignment intent", - options: assignmentIntentOptions, - defaultValue: "Required", - validators: { required: "Select an assignment intent" }, - helperText: - "Available assigns to Company Portal, Required installs automatically, Uninstall removes the app, Available without enrollment exposes it without device enrollment.", - }, - { - type: "radio", - name: "assignmentMode", - label: "Assignment mode", - options: assignmentModeOptions, - defaultValue: "replace", - helperText: - "Replace will overwrite existing assignments. Append keeps current assignments and adds/overwrites only for the selected groups/intents.", - }, - ...getAssignmentFilterFields(), - ], - customDataformatter: (row, action, formData) => { - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; - return { - tenantFilter: tenantFilterValue, - ID: row?.id, - AssignTo: "AllDevicesAndUsers", - Intent: formData?.Intent || "Required", - assignmentMode: formData?.assignmentMode || "replace", - AssignmentFilterName: formData?.assignmentFilter?.value || null, - AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" - : null, - }; - }, + fields: assignmentFields, + customDataformatter: makeAssignFormatter((_singleRow, formData) => ({ + AssignTo: "AllDevicesAndUsers", + Intent: formData?.Intent || "Required", + assignmentMode: formData?.assignmentMode || "replace", + })), confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', icon: , color: "info", @@ -252,23 +204,15 @@ const Page = () => { }, ...getAssignmentFilterFields(), ], - customDataformatter: (row, action, formData) => { + customDataformatter: makeAssignFormatter((_singleRow, formData) => { const selectedGroups = Array.isArray(formData?.groupTargets) ? formData.groupTargets : []; - const tenantFilterValue = tenant === "AllTenants" && row?.Tenant ? row.Tenant : tenant; return { - tenantFilter: tenantFilterValue, - ID: row?.id, GroupIds: selectedGroups.map((group) => group.value).filter(Boolean), GroupNames: selectedGroups.map((group) => group.label).filter(Boolean), Intent: formData?.assignmentIntent || "Required", AssignmentMode: formData?.assignmentMode || "replace", - AppType: getAppAssignmentSettingsType(row?.["@odata.type"]), - AssignmentFilterName: formData?.assignmentFilter?.value || null, - AssignmentFilterType: formData?.assignmentFilter?.value - ? formData?.assignmentFilterType || "include" - : null, }; - }, + }), }, { label: "Delete Application", From eead9e28f14e9b1903d883eaaa203fcc67a668b7 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Fri, 20 Mar 2026 00:04:59 +0100 Subject: [PATCH 04/35] add "Create Template from User" action --- .../CippComponents/CippUserActions.jsx | 50 +++++++++++++++++++ .../CippFormPages/CippAddEditUser.jsx | 5 ++ 2 files changed, 55 insertions(+) diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index 0b365b506c77..025a876fbd82 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -4,6 +4,7 @@ import { Archive, Clear, CloudDone, + ContentCopy, Edit, Email, ForwardToInbox, @@ -287,6 +288,55 @@ export const useCippUserActions = () => { target: "_self", condition: () => canWriteUser, }, + { + label: "Create Template from User", + type: "POST", + icon: , + url: "/api/AddUserDefaults", + fields: [ + { + type: "textField", + name: "templateName", + label: "Template Name", + validators: { required: "Please enter a template name" }, + }, + { + type: "switch", + name: "defaultForTenant", + label: "Default for Tenant", + }, + ], + customDataformatter: (row, action, formData) => { + const user = Array.isArray(row) ? row[0] : row; + const licenses = + user.assignedLicenses?.map((l) => ({ + label: getCippLicenseTranslation([l])?.[0] || l.skuId, + value: l.skuId, + })) || []; + return { + tenantFilter: tenant, + templateName: formData.templateName, + defaultForTenant: formData.defaultForTenant || false, + sourceUserId: user.id, + jobTitle: user.jobTitle || "", + department: user.department || "", + streetAddress: user.streetAddress || "", + city: user.city || "", + state: user.state || "", + postalCode: user.postalCode || "", + country: user.country || "", + companyName: user.companyName || "", + mobilePhone: user.mobilePhone || "", + "businessPhones[0]": user.businessPhones?.[0] || "", + usageLocation: user.usageLocation || "", + licenses: licenses, + }; + }, + confirmText: + "Create a new user default template based on [displayName]'s properties (job title, department, location, licenses, and group memberships).", + multiPost: false, + condition: () => canWriteUser, + }, { //tested label: "Research Compromised Account", diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index b479cacd197a..412ae4a17e3b 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -249,6 +249,11 @@ const CippAddEditUser = (props) => { if (template.licenses && Array.isArray(template.licenses)) { setFieldIfEmpty("licenses", template.licenses); } + + // Pass stored group memberships from template to user creation + if (template.groupMemberships && Array.isArray(template.groupMemberships) && template.groupMemberships.length > 0) { + formControl.setValue("groupMemberships", template.groupMemberships); + } } }, [watcher.userTemplate, formType]); From 0615839e04e05a518e933744b5ac40c053559c02 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Mar 2026 22:45:08 -0400 Subject: [PATCH 05/35] feat: add Custom Subject field and enhance Notification Settings layout in AlertWizard --- .../alert-configuration/alert.jsx | 144 +++++++++++------- 1 file changed, 89 insertions(+), 55 deletions(-) diff --git a/src/pages/tenant/administration/alert-configuration/alert.jsx b/src/pages/tenant/administration/alert-configuration/alert.jsx index 577134d1fcc6..26026bb4b984 100644 --- a/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -184,6 +184,7 @@ const AlertWizard = () => { recurrence: recurrenceOption, postExecution: postExecutionValue, startDateTime: startDateTimeForForm, + CustomSubject: alert.RawAlert.CustomSubject || "", AlertComment: alert.RawAlert.AlertComment || "", }; if (usedCommand?.requiresInput && alert.RawAlert.Parameters) { @@ -256,6 +257,7 @@ const AlertWizard = () => { Actions: alert.RawAlert.Actions, logbook: foundLogbook, AlertComment: alert.RawAlert.AlertComment || "", + CustomSubject: alert.RawAlert.CustomSubject || "", conditions: [], // Include empty array to register field structure }; // Reset first without spawning rows to avoid rendering empty operator fields @@ -477,7 +479,9 @@ const AlertWizard = () => { RowKey: router.query.clone ? undefined : router.query.id ? router.query.id : undefined, tenantFilter: values.tenantFilter, excludedTenants: values.excludedTenants, - Name: `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.command.label}`, + Name: values.CustomSubject + ? `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.CustomSubject}` + : `${values.tenantFilter?.label || values.tenantFilter?.value}: ${values.command.label}`, Command: { value: `Get-CIPPAlert${values.command.value.name}` }, Parameters: getInputParams(), ScheduledTime: Math.floor(new Date().getTime() / 1000) + 60, @@ -485,6 +489,7 @@ const AlertWizard = () => { Recurrence: values.recurrence, PostExecution: values.postExecution, AlertComment: values.AlertComment, + CustomSubject: values.CustomSubject, }; apiRequest.mutate( { url: "/api/AddScheduledItem?hidden=true", data: postObject }, @@ -600,19 +605,7 @@ const AlertWizard = () => { - } - > - Save Alert - - } - sx={{ mb: 3 }} - > + { ))} + + + + } + > + Save Alert + + } + > { options={actionsToTake} /> + + + { placeholder="Add documentation, FAQ links, or instructions for when this alert triggers..." /> + @@ -897,19 +916,7 @@ const AlertWizard = () => { - } - > - Save Alert - - } - > + { ))} - - - - - - - - - + + + + + + } + > + Save Alert + + } + > + + + + + + + + + + + + From 4b5c6c79e8eee6cf84002e17b2c0c9c6976d1ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= <31723128+kris6673@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:52:26 +0100 Subject: [PATCH 06/35] fix: Remove 'scope' from Scheduler Removed 'scope' property from the Scheduler configuration. --- src/layouts/config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/layouts/config.js b/src/layouts/config.js index 4f8be5c348c7..db973f913e4a 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -903,7 +903,6 @@ export const nativeMenuItems = [ path: "/cipp/scheduler", roles: ["editor", "admin", "superadmin"], permissions: ["CIPP.Scheduler.*"], - scope: "global", }, ], }, From ef26c18fce46620333f815d4e29f390faf74196b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= <31723128+kris6673@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:04:11 +0100 Subject: [PATCH 07/35] fix: Remove tenantInTitle prop from CippSchedulerDrawer --- src/pages/cipp/scheduler/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/cipp/scheduler/index.js b/src/pages/cipp/scheduler/index.js index 73b2396f5171..446f7ca0593f 100644 --- a/src/pages/cipp/scheduler/index.js +++ b/src/pages/cipp/scheduler/index.js @@ -66,7 +66,6 @@ const Page = () => { } - tenantInTitle={false} title="Scheduled Tasks" apiUrl={ showHiddenJobs ? `/api/ListScheduledItems?ShowHidden=true` : `/api/ListScheduledItems` From 2209de70445f71e1ce8cde7a8a66abc9729bba82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Fri, 20 Mar 2026 13:33:24 +0100 Subject: [PATCH 08/35] fix(CippApiResults): prevent alert from overflowing drawer on narrow screens/drawers Add `minWidth: 0` to the Stack so CSS Grid can constrain it to the cell width. Without this, the action buttons (Get Help, copy, close) forced the Alert wider than the drawer on mobile/narrow viewports. --- src/components/CippComponents/CippApiResults.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index 777fc9d07283..1c4ca364c362 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -244,7 +244,7 @@ export const CippApiResults = (props) => { const hasVisibleResults = finalResults.some((r) => r.visible); return ( - + {/* Loading alert */} {!errorsOnly && ( From f75df34ab7755b3128f1354f9b63797a429b68f6 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sat, 21 Mar 2026 23:04:20 +0100 Subject: [PATCH 09/35] fix(group-templates): allow resubmit on template edit page --- src/pages/identity/administration/group-templates/edit.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/identity/administration/group-templates/edit.jsx b/src/pages/identity/administration/group-templates/edit.jsx index 5fd7f3417805..8c83b0461005 100644 --- a/src/pages/identity/administration/group-templates/edit.jsx +++ b/src/pages/identity/administration/group-templates/edit.jsx @@ -46,6 +46,7 @@ const Page = () => { allowExternal: templateData.allowExternal, tenantFilter: userSettingsDefaults.currentTenant, }); + formControl.trigger(); } } }, [template, formControl, userSettingsDefaults.currentTenant]); From 52e2bbec0c033b657f01bbfdd8b27a018562fc53 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sun, 22 Mar 2026 01:37:37 +0100 Subject: [PATCH 10/35] feat(security): add MDE onboarding status page --- src/layouts/config.js | 7 +- .../security/reports/mde-onboarding/index.js | 235 ++++++++++++++++++ 2 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/pages/security/reports/mde-onboarding/index.js diff --git a/src/layouts/config.js b/src/layouts/config.js index 4f8be5c348c7..a41154603b3d 100644 --- a/src/layouts/config.js +++ b/src/layouts/config.js @@ -347,13 +347,18 @@ export const nativeMenuItems = [ }, { title: "Reports", - permissions: ["Tenant.DeviceCompliance.*"], + permissions: ["Tenant.DeviceCompliance.*", "Security.Defender.*"], items: [ { title: "Device Compliance", path: "/security/reports/list-device-compliance", permissions: ["Tenant.DeviceCompliance.*"], }, + { + title: "MDE Onboarding", + path: "/security/reports/mde-onboarding", + permissions: ["Security.Defender.*"], + }, ], }, { diff --git a/src/pages/security/reports/mde-onboarding/index.js b/src/pages/security/reports/mde-onboarding/index.js new file mode 100644 index 000000000000..015653b411c1 --- /dev/null +++ b/src/pages/security/reports/mde-onboarding/index.js @@ -0,0 +1,235 @@ +import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; +import { useSettings } from "../../../../hooks/use-settings"; +import { + Card, + CardContent, + CardHeader, + Chip, + Container, + Stack, + Typography, + CircularProgress, + Button, + SvgIcon, + IconButton, + Tooltip, +} from "@mui/material"; +import { Sync, Info, OpenInNew } from "@mui/icons-material"; +import { ApiGetCall } from "../../../../api/ApiCall"; +import { CippHead } from "../../../../components/CippComponents/CippHead"; +import { useDialog } from "../../../../hooks/use-dialog"; +import { CippApiDialog } from "../../../../components/CippComponents/CippApiDialog"; +import { CippQueueTracker } from "../../../../components/CippTable/CippQueueTracker"; +import { useState } from "react"; + +const statusColors = { + enabled: "success", + available: "success", + unavailable: "error", + unresponsive: "warning", + notSetUp: "default", + error: "error", +}; + +const statusLabels = { + enabled: "Enabled", + available: "Available", + unavailable: "Unavailable", + unresponsive: "Unresponsive", + notSetUp: "Not Set Up", + error: "Error", +}; + +const SingleTenantView = ({ tenant }) => { + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + + const tenantList = ApiGetCall({ + url: "/api/listTenants", + queryKey: "TenantSelector", + }); + + const tenantId = tenantList.data?.find( + (t) => t.defaultDomainName === tenant + )?.customerId; + + const { data, isFetching } = ApiGetCall({ + url: "/api/ListMDEOnboarding", + queryKey: `MDEOnboarding-${tenant}`, + data: { tenantFilter: tenant, UseReportDB: true }, + waiting: true, + }); + + const item = Array.isArray(data) ? data[0] : data; + const status = item?.partnerState || "Unknown"; + + return ( + <> + + + + + + + + + + + + + } + /> + + {isFetching ? ( + + ) : ( + + + Status: + + + {item?.CacheTimestamp && ( + + Last synced: {new Date(item.CacheTimestamp).toLocaleString()} + + )} + {item?.error && ( + + {item.error} + + )} + {tenantId && status !== "enabled" && status !== "available" && ( + + )} + + )} + + + + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result.Metadata.QueueId); + } + }, + }} + /> + + ); +}; + +const Page = () => { + const currentTenant = useSettings().currentTenant; + const isAllTenants = currentTenant === "AllTenants"; + const syncDialog = useDialog(); + const [syncQueueId, setSyncQueueId] = useState(null); + + if (!isAllTenants) { + return ; + } + + const pageActions = [ + + + + + + + + + , + ]; + + return ( + <> + + { + if (result?.Metadata?.QueueId) { + setSyncQueueId(result.Metadata.QueueId); + } + }, + }} + /> + + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; \ No newline at end of file From b5bd2bab080814c4b7af3040de32664ab1add688 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:37:51 +0800 Subject: [PATCH 11/35] pr --- .../update_license_skus_frontend.yml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/update_license_skus_frontend.yml diff --git a/.github/workflows/update_license_skus_frontend.yml b/.github/workflows/update_license_skus_frontend.yml new file mode 100644 index 000000000000..d3e0c7a35dd3 --- /dev/null +++ b/.github/workflows/update_license_skus_frontend.yml @@ -0,0 +1,44 @@ +name: Update License SKUs (Frontend) + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: write + pull-requests: write + +jobs: + update-frontend-skus: + if: github.repository_owner == 'Zacgoose' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download shared SKU update script + id: download-script + shell: pwsh + run: | + $scriptPath = Join-Path $env:RUNNER_TEMP 'Update-LicenseSKUFiles.ps1' + Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/Zacgoose/CIPP-API/dev/Tools/Update-LicenseSKUFiles.ps1' -OutFile $scriptPath -ErrorAction Stop + + "script_path=$scriptPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Update frontend SKU files + shell: pwsh + run: | + & "${{ steps.download-script.outputs.script_path }}" -Target frontend -FrontendRepoPath "${{ github.workspace }}" + + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: 'chore: update frontend license SKU files' + title: 'chore: update frontend license SKU files' + body: 'Automated weekly update of frontend Microsoft license SKU data.' + branch: chore/update-frontend-license-skus + base: dev + delete-branch: true + add-paths: src/data/*M365Licenses.json From 8f51153862e781cda64a7c75bdae4dba0527344b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:40:22 +0800 Subject: [PATCH 12/35] Revert "pr" This reverts commit b5bd2bab080814c4b7af3040de32664ab1add688. --- .../update_license_skus_frontend.yml | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/update_license_skus_frontend.yml diff --git a/.github/workflows/update_license_skus_frontend.yml b/.github/workflows/update_license_skus_frontend.yml deleted file mode 100644 index d3e0c7a35dd3..000000000000 --- a/.github/workflows/update_license_skus_frontend.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Update License SKUs (Frontend) - -on: - workflow_dispatch: - schedule: - - cron: '0 6 * * 1' - -permissions: - contents: write - pull-requests: write - -jobs: - update-frontend-skus: - if: github.repository_owner == 'Zacgoose' - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download shared SKU update script - id: download-script - shell: pwsh - run: | - $scriptPath = Join-Path $env:RUNNER_TEMP 'Update-LicenseSKUFiles.ps1' - Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/Zacgoose/CIPP-API/dev/Tools/Update-LicenseSKUFiles.ps1' -OutFile $scriptPath -ErrorAction Stop - - "script_path=$scriptPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - - - name: Update frontend SKU files - shell: pwsh - run: | - & "${{ steps.download-script.outputs.script_path }}" -Target frontend -FrontendRepoPath "${{ github.workspace }}" - - - name: Create pull request - uses: peter-evans/create-pull-request@v7 - with: - commit-message: 'chore: update frontend license SKU files' - title: 'chore: update frontend license SKU files' - body: 'Automated weekly update of frontend Microsoft license SKU data.' - branch: chore/update-frontend-license-skus - base: dev - delete-branch: true - add-paths: src/data/*M365Licenses.json From a37a0649f1fe9e1cff6535c550b242733f1be337 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Mon, 23 Mar 2026 10:42:07 +0000 Subject: [PATCH 13/35] Update CippTextFieldWithVariables.jsx - Fix variable insertion not tracking cursor correctly on multiline fields --- .../CippTextFieldWithVariables.jsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/CippComponents/CippTextFieldWithVariables.jsx b/src/components/CippComponents/CippTextFieldWithVariables.jsx index 80d720ac9440..6c083ead4be1 100644 --- a/src/components/CippComponents/CippTextFieldWithVariables.jsx +++ b/src/components/CippComponents/CippTextFieldWithVariables.jsx @@ -33,7 +33,12 @@ export const CippTextFieldWithVariables = ({ // Track cursor position const handleSelectionChange = () => { if (textFieldRef.current) { - setCursorPosition(textFieldRef.current.selectionStart || 0); + // Check for input first, then textarea + const inputElement = + textFieldRef.current.querySelector("input") || + textFieldRef.current.querySelector("textarea") || + textFieldRef.current; + setCursorPosition(inputElement?.selectionStart || 0); } }; @@ -97,7 +102,11 @@ export const CippTextFieldWithVariables = ({ // Get fresh cursor position from the DOM let cursorPos = cursorPosition; if (textFieldRef.current) { - const inputElement = textFieldRef.current.querySelector("input") || textFieldRef.current; + // Check for input first, then textarea + const inputElement = + textFieldRef.current.querySelector("input") || + textFieldRef.current.querySelector("textarea") || + textFieldRef.current; if (inputElement && typeof inputElement.selectionStart === "number") { cursorPos = inputElement.selectionStart; } @@ -128,8 +137,11 @@ export const CippTextFieldWithVariables = ({ const newCursorPos = lastPercentIndex + variableString.length; // Access the actual input element for Material-UI TextField + // Check for input first, then textarea const inputElement = - textFieldRef.current.querySelector("input") || textFieldRef.current; + textFieldRef.current.querySelector("input") || + textFieldRef.current.querySelector("textarea") || + textFieldRef.current; if (inputElement && inputElement.setSelectionRange) { inputElement.setSelectionRange(newCursorPos, newCursorPos); inputElement.focus(); From d4fa117c3112f02de797f7c99a2599b7c05cb3bd Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:19:19 +0100 Subject: [PATCH 14/35] feat: add tooltip for truncated data in getCippFormatting Enhanced the getCippFormatting function to display a tooltip for truncated data in hardware and message fields. This improves user experience by providing full context on hover. --- src/utils/get-cipp-formatting.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/utils/get-cipp-formatting.js b/src/utils/get-cipp-formatting.js index 79139e2b2347..5e17c3735042 100644 --- a/src/utils/get-cipp-formatting.js +++ b/src/utils/get-cipp-formatting.js @@ -10,7 +10,7 @@ import { PrecisionManufacturing, BarChart, } from "@mui/icons-material"; -import { Chip, Link, SvgIcon } from "@mui/material"; +import { Chip, Link, SvgIcon, Tooltip } from "@mui/material"; import { alpha } from "@mui/material/styles"; import { Box } from "@mui/system"; import { CippCopyToClipBoard } from "../components/CippComponents/CippCopyToClipboard"; @@ -220,7 +220,11 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr (hardwareHashFields.includes(cellName) || cellNameLower.includes("hardware")) ) { if (data.length > 15) { - return isText ? data : `${data.substring(0, 15)}...`; + return isText ? data : ( + + {data.substring(0, 15)}... + + ); } return isText ? data : data; } @@ -229,7 +233,11 @@ export const getCippFormatting = (data, cellName, type, canReceive, flatten = tr const messageFields = ["Message"]; if (messageFields.includes(cellName)) { if (typeof data === "string" && data.length > 120) { - return isText ? data : `${data.substring(0, 120)}...`; + return isText ? data : ( + + {data.substring(0, 120)}... + + ); } return isText ? data : data; } From a78136cec35830247cf32e5fda23f50965cd1bde Mon Sep 17 00:00:00 2001 From: k-grube Date: Mon, 23 Mar 2026 09:03:50 -0700 Subject: [PATCH 15/35] chore: update gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 78ea4526e7ce..44dac6dd492c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,10 @@ app.log # AI rules .*/rules AGENTS.md + +# jetbrains +.idea + +# azurite +__* +AzuriteConfig From 390490975e2e7753383330d323b227f622643bcd Mon Sep 17 00:00:00 2001 From: k-grube Date: Mon, 23 Mar 2026 09:16:49 -0700 Subject: [PATCH 16/35] chore: add react type defs --- package.json | 2 ++ yarn.lock | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 1fe17fc166a6..6c8daea53c0f 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,8 @@ }, "devDependencies": { "@svgr/webpack": "8.1.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "eslint": "9.39.2", "eslint-config-next": "16.1.6" } diff --git a/yarn.lock b/yarn.lock index 388c3f43e078..d1493baeab00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2487,6 +2487,11 @@ resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.3.tgz#85f1d1d17569b28b8db45e16e996407a56b0ab04" integrity sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw== +"@types/react-dom@^19.2.3": + version "19.2.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== + "@types/react-redux@^7.1.20": version "7.1.34" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.34.tgz#83613e1957c481521e6776beeac4fd506d11bd0e" @@ -2502,7 +2507,7 @@ resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@*": +"@types/react@*", "@types/react@^19.2.14": version "19.2.14" resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad" integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== From 28be83881275aa0e68b469db5f7418d07b9d560a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:14:57 +0800 Subject: [PATCH 17/35] Add UI action to clear HIBP API key Import ApiPostCall and add a clearHIBPKey mutation to the integrations configure page. Render a "Clear API Key" button for the HIBP extension that calls /api/ExecExtensionClearHIBPKey and disables while pending. The mutation invalidates the "Integrations" query, and its results are shown via CippApiResults. This provides a way to clear the stored HIBP API key and refresh integration state. --- src/pages/cipp/integrations/configure.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/pages/cipp/integrations/configure.js b/src/pages/cipp/integrations/configure.js index 330e03d55b13..578317466c54 100644 --- a/src/pages/cipp/integrations/configure.js +++ b/src/pages/cipp/integrations/configure.js @@ -13,7 +13,7 @@ import CippIntegrationSettings from "../../../components/CippIntegrations/CippIn import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { useForm } from "react-hook-form"; import { useSettings } from "../../../hooks/use-settings"; -import { ApiGetCall } from "../../../api/ApiCall"; +import { ApiGetCall, ApiPostCall } from "../../../api/ApiCall"; import { useRouter } from "next/router"; import extensions from "../../../data/Extensions.json"; import { useEffect } from "react"; @@ -74,6 +74,9 @@ const Page = () => { const actionSyncResults = ApiGetCall({ ...syncQuery, }); + const clearHIBPKey = ApiPostCall({ + relatedQueryKeys: ["Integrations"], + }); const handleIntegrationSync = () => { setSyncQuery({ url: "/api/ExecExtensionSync", @@ -191,6 +194,23 @@ const Page = () => { )} + {extension?.id === "HIBP" && ( + + + + )} {extension?.links && ( <> {extension.links.map((link, index) => ( @@ -208,6 +228,7 @@ const Page = () => { + From 4fc72a414895f6e7c0f0e24f1600864a8955124a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:06:56 +0800 Subject: [PATCH 18/35] Add standardized schema toggle to form Introduce a UseStandardizedSchema option to the notification form. The changes set a default false value when loading config and add a switch control labeled "Use Standardized Alert Schema" with helper text explaining it enables a consistent JSON schema for webhook alerts (improves Power Automate / Logic Apps integration). The switch is disabled while notification config is being fetched; default remains off for backward compatibility. --- src/components/CippComponents/CippNotificationForm.jsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/CippComponents/CippNotificationForm.jsx b/src/components/CippComponents/CippNotificationForm.jsx index 9f499e4ac4c1..0621a6cb305e 100644 --- a/src/components/CippComponents/CippNotificationForm.jsx +++ b/src/components/CippComponents/CippNotificationForm.jsx @@ -69,6 +69,7 @@ export const CippNotificationForm = ({ onePerTenant: listNotificationConfig.data?.onePerTenant, sendtoIntegration: listNotificationConfig.data?.sendtoIntegration, includeTenantId: listNotificationConfig.data?.includeTenantId, + UseStandardizedSchema: listNotificationConfig.data?.UseStandardizedSchema || false, }); } }, [listNotificationConfig.isSuccess]); @@ -136,6 +137,14 @@ export const CippNotificationForm = ({ name="sendtoIntegration" formControl={formControl} /> + {showTestButton && ( From f88e0442df3dc3d73db1ff56231a8ad756ce3c0c Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 24 Mar 2026 13:13:34 +0000 Subject: [PATCH 19/35] Update licenses.js - Added form dropdown to license exclusion flow --- src/pages/cipp/settings/licenses.js | 111 +++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 17 deletions(-) diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index b5816cadde66..e2d34db85c9c 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -2,11 +2,15 @@ import tabOptions from "./tabOptions"; import { TabbedLayout } from "../../../layouts/TabbedLayout"; import { Layout as DashboardLayout } from "../../../layouts/index.js"; import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; -import { Button, SvgIcon, Stack } from "@mui/material"; +import { Button, SvgIcon, Stack, Box } from "@mui/material"; import { TrashIcon } from "@heroicons/react/24/outline"; import { Add, RestartAlt } from "@mui/icons-material"; import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; import { useDialog } from "../../../hooks/use-dialog"; +import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; +import { CippFormCondition } from "../../../components/CippComponents/CippFormCondition"; +import M365Licenses from "../../../data/M365Licenses.json"; +import { useMemo } from "react"; const Page = () => { const pageTitle = "Excluded Licenses"; @@ -15,6 +19,22 @@ const Page = () => { const resetDialog = useDialog(); const simpleColumns = ["Product_Display_Name", "GUID"]; + // Deduplicate licenses by GUID and create autocomplete options + const licenseOptions = useMemo(() => { + const uniqueLicenses = new Map(); + M365Licenses.forEach((license) => { + if (!uniqueLicenses.has(license.GUID)) { + uniqueLicenses.set(license.GUID, { + label: license.Product_Display_Name, + value: license.GUID, + }); + } + }); + return Array.from(uniqueLicenses.values()).sort((a, b) => + a.label.localeCompare(b.label) + ); + }, []); + const actions = [ { label: "Delete Exclusion", @@ -81,30 +101,87 @@ const Page = () => { { + if (formData.advancedMode) { + return { + Action: "AddExclusion", + GUID: formData.GUID, + SKUName: formData.SKUName, + }; + } else { + return { + Action: "AddExclusion", + GUID: formData.selectedLicense?.value, + SKUName: formData.selectedLicense?.label, + }; + } + }, }} - /> + > + {({ formHook }) => ( + <> + + + + + + + + + + + + + + + + )} + Date: Tue, 24 Mar 2026 13:49:01 +0000 Subject: [PATCH 20/35] Update licenses.js --- src/pages/cipp/settings/licenses.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index e2d34db85c9c..f2754636bfb7 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -9,6 +9,7 @@ import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog" import { useDialog } from "../../../hooks/use-dialog"; import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; import { CippFormCondition } from "../../../components/CippComponents/CippFormCondition"; +import { ApiGetCall } from "../../../api/ApiCall"; import M365Licenses from "../../../data/M365Licenses.json"; import { useMemo } from "react"; @@ -19,7 +20,16 @@ const Page = () => { const resetDialog = useDialog(); const simpleColumns = ["Product_Display_Name", "GUID"]; - // Deduplicate licenses by GUID and create autocomplete options + const excludedLicenses = ApiGetCall({ + url: "/api/ListExcludedLicenses", + queryKey: "ExcludedLicenses", + }); + + const excludedGuids = useMemo( + () => excludedLicenses.data?.Results?.map((license) => license.GUID) || [], + [excludedLicenses.data] + ); + const licenseOptions = useMemo(() => { const uniqueLicenses = new Map(); M365Licenses.forEach((license) => { @@ -148,6 +158,7 @@ const Page = () => { name="selectedLicense" label="Select License" options={licenseOptions} + removeOptions={excludedGuids} formControl={formHook} multiple={false} validators={{ required: "Please select a license" }} From fe4fd23a39c7041994abf9e6c69ae75cfcae595d Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 24 Mar 2026 14:10:18 +0000 Subject: [PATCH 21/35] Update licenses.js --- src/pages/cipp/settings/licenses.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index f2754636bfb7..b785792fc97f 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -25,15 +25,13 @@ const Page = () => { queryKey: "ExcludedLicenses", }); - const excludedGuids = useMemo( - () => excludedLicenses.data?.Results?.map((license) => license.GUID) || [], - [excludedLicenses.data] - ); - const licenseOptions = useMemo(() => { + const excludedGuids = new Set( + excludedLicenses.data?.Results?.map((license) => license.GUID) || [] + ); const uniqueLicenses = new Map(); M365Licenses.forEach((license) => { - if (!uniqueLicenses.has(license.GUID)) { + if (!uniqueLicenses.has(license.GUID) && !excludedGuids.has(license.GUID)) { uniqueLicenses.set(license.GUID, { label: license.Product_Display_Name, value: license.GUID, @@ -43,7 +41,7 @@ const Page = () => { return Array.from(uniqueLicenses.values()).sort((a, b) => a.label.localeCompare(b.label) ); - }, []); + }, [excludedLicenses.data]); const actions = [ { @@ -158,7 +156,6 @@ const Page = () => { name="selectedLicense" label="Select License" options={licenseOptions} - removeOptions={excludedGuids} formControl={formHook} multiple={false} validators={{ required: "Please select a license" }} From acfd6c411538fe5f0e177247ac96f5f1cdba4da7 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 24 Mar 2026 14:32:54 -0400 Subject: [PATCH 22/35] feat: DeployCheckChromeExtension to use CyberDrain branding and enhance configuration options --- src/data/standards.json | 71 +++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/src/data/standards.json b/src/data/standards.json index fe08f2fcbb45..d68360bd98d3 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -5847,15 +5847,21 @@ "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.", + "helpText": "Deploys the Check by CyberDrain browser extension via a Win32 script app in Intune for both Chrome and Edge browsers with configurable settings. Chrome ID: benimdeioplgkhanklclahllklceahbe, Edge ID: knepjpocdagponkonnbggpcnhnaikajg", + "docsDescription": "Creates an Intune Win32 script application that writes registry keys to install and configure the Check by CyberDrain browser extension on managed devices for both Google Chrome and Microsoft Edge browsers. Uses a PowerShell detection script to enforce configuration drift — when settings change in CIPP the app is automatically redeployed.", + "executiveText": "Automatically deploys the Check by CyberDrain 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.showNotifications", + "label": "Show notifications", + "defaultValue": true + }, { "type": "switch", "name": "standards.DeployCheckChromeExtension.enableValidPageBadge", "label": "Enable valid page badge", - "defaultValue": true + "defaultValue": false }, { "type": "switch", @@ -5863,6 +5869,12 @@ "label": "Enable page blocking", "defaultValue": true }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.forceToolbarPin", + "label": "Force pin extension to toolbar", + "defaultValue": false + }, { "type": "switch", "name": "standards.DeployCheckChromeExtension.enableCippReporting", @@ -5874,13 +5886,14 @@ "name": "standards.DeployCheckChromeExtension.customRulesUrl", "label": "Custom Rules URL", "placeholder": "https://YOUR-CIPP-SERVER-URL/rules.json", + "helperText": "Enter the URL for custom rules if you have them. This should point to a JSON file with the same structure as the rules.json used for CIPP reporting.", "required": false }, { "type": "number", "name": "standards.DeployCheckChromeExtension.updateInterval", "label": "Update interval (hours)", - "defaultValue": 12 + "defaultValue": 24 }, { "type": "switch", @@ -5888,6 +5901,39 @@ "label": "Enable debug logging", "defaultValue": false }, + { + "type": "switch", + "name": "standards.DeployCheckChromeExtension.enableGenericWebhook", + "label": "Enable generic webhook", + "defaultValue": false + }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.webhookUrl", + "label": "Webhook URL", + "placeholder": "https://webhook.example.com/endpoint", + "required": false + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "name": "standards.DeployCheckChromeExtension.webhookEvents", + "label": "Webhook Events", + "placeholder": "e.g. pageBlocked, pageAllowed" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "freeSolo": true, + "name": "standards.DeployCheckChromeExtension.urlAllowlist", + "label": "URL Allowlist", + "placeholder": "e.g. https://example.com/*", + "helperText": "Enter URLs to allowlist in the extension. Press enter to add each URL. Wildcards are allowed. This should be used for sites that are being blocked by the extension but are known to be safe." + }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.companyName", @@ -5895,6 +5941,13 @@ "placeholder": "YOUR-COMPANY", "required": false }, + { + "type": "textField", + "name": "standards.DeployCheckChromeExtension.companyURL", + "label": "Company URL", + "placeholder": "https://yourcompany.com", + "required": false + }, { "type": "textField", "name": "standards.DeployCheckChromeExtension.productName", @@ -5913,7 +5966,7 @@ "type": "textField", "name": "standards.DeployCheckChromeExtension.primaryColor", "label": "Primary Color", - "placeholder": "#0044CC", + "placeholder": "#F77F00", "required": false }, { @@ -5925,7 +5978,7 @@ }, { "name": "AssignTo", - "label": "Who should this policy be assigned to?", + "label": "Who should this app be assigned to?", "type": "radio", "options": [ { @@ -5957,11 +6010,11 @@ "label": "Enter the custom group name if you selected 'Assign to Custom Group'. Wildcards are allowed." } ], - "label": "Deploy Check Chrome Extension", + "label": "Deploy Check by CyberDrain Browser Extension", "impact": "Low Impact", "impactColour": "info", "addedDate": "2025-09-18", - "powershellEquivalent": "New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies'", + "powershellEquivalent": "Add-CIPPW32ScriptApplication", "recommendedBy": ["CIPP"] }, { From 824d66fed7778dc390610e7e2b2bc59ce8826e5a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 24 Mar 2026 15:08:02 -0400 Subject: [PATCH 23/35] feat: add detection script support to custom application --- .../CippApplicationDeployDrawer.jsx | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx index 410be49561c0..7d6d2366301f 100644 --- a/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -869,22 +869,55 @@ export const CippApplicationDeployDrawer = ({ rows={6} /> - - - - + + + + + + + + + + + + + + Date: Tue, 24 Mar 2026 17:24:42 -0400 Subject: [PATCH 24/35] chore: remove merge conflict markers --- src/pages/identity/administration/jit-admin/add.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pages/identity/administration/jit-admin/add.jsx b/src/pages/identity/administration/jit-admin/add.jsx index 34f39fa8e5db..16842bd313f6 100644 --- a/src/pages/identity/administration/jit-admin/add.jsx +++ b/src/pages/identity/administration/jit-admin/add.jsx @@ -29,7 +29,6 @@ const Page = () => { }); const watcher = useWatch({ control: formControl.control }); -<<<<<<< fix/jit-admin-tap-policy-check const useTAP = useWatch({ control: formControl.control, name: "UseTAP" }); const tapPolicy = ApiGetCall({ @@ -44,7 +43,6 @@ const Page = () => { waiting: !!selectedTenant, }); const tapEnabled = tapPolicy.isSuccess && tapPolicy.data?.Results?.[0]?.state === "enabled"; -======= const useRoles = useWatch({ control: formControl.control, name: "useRoles" }); const useGroups = useWatch({ control: formControl.control, name: "useGroups" }); @@ -78,7 +76,6 @@ const Page = () => { formControl.setValue("expireAction", null); } }, [useRoles, useGroups]); ->>>>>>> dev // Simple duration parser for basic ISO 8601 durations const parseDuration = (duration) => { From a46d96f11f12f3fad4d35710d186b114622609b1 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:52:33 +0800 Subject: [PATCH 25/35] Add webhook auth types and tenant filter Introduce webhook authentication options and related form fields for Notification settings. Imports CippFormCondition and useSettings, adds a webhookAuthTypes list, and loads additional webhook auth values (type, token, username, password, header name/value, headers) from API config. Adds conditional UI controls for Bearer, Basic, API Key Header, and Custom Headers (JSON). Updates effect deps to include dataUpdatedAt, changes Send Test Alert button disabling to depend on form dirty state, and includes currentTenant as tenantFilter in the test alert POST payload. --- .../CippComponents/CippNotificationForm.jsx | 138 +++++++++++++++++- 1 file changed, 135 insertions(+), 3 deletions(-) diff --git a/src/components/CippComponents/CippNotificationForm.jsx b/src/components/CippComponents/CippNotificationForm.jsx index 0621a6cb305e..03ecce096ec9 100644 --- a/src/components/CippComponents/CippNotificationForm.jsx +++ b/src/components/CippComponents/CippNotificationForm.jsx @@ -2,17 +2,20 @@ import { useEffect } from "react"; import { Button, Box } from "@mui/material"; import { Grid } from "@mui/system"; import CippFormComponent from "./CippFormComponent"; +import { CippFormCondition } from "./CippFormCondition"; import { ApiGetCall } from "../../api/ApiCall"; import { useDialog } from "../../hooks/use-dialog"; import { CippApiDialog } from "./CippApiDialog"; import { useFormState } from "react-hook-form"; +import { useSettings } from "../../hooks/use-settings"; export const CippNotificationForm = ({ formControl, showTestButton = true, - hideButtons = false, }) => { const notificationDialog = useDialog(); + const settings = useSettings(); + const currentTenant = settings.currentTenant; // API call to get notification configuration const listNotificationConfig = ApiGetCall({ @@ -49,6 +52,14 @@ export const CippNotificationForm = ({ { label: "Critical", value: "Critical" }, ]; + const webhookAuthTypes = [ + { label: "None", value: "None" }, + { label: "Bearer Token", value: "Bearer" }, + { label: "Basic Auth", value: "Basic" }, + { label: "API Key Header", value: "ApiKey" }, + { label: "Custom Headers (JSON)", value: "CustomHeaders" }, + ]; + // Load notification config data into form useEffect(() => { if (listNotificationConfig.isSuccess) { @@ -70,9 +81,18 @@ export const CippNotificationForm = ({ sendtoIntegration: listNotificationConfig.data?.sendtoIntegration, includeTenantId: listNotificationConfig.data?.includeTenantId, UseStandardizedSchema: listNotificationConfig.data?.UseStandardizedSchema || false, + webhookAuthType: webhookAuthTypes.find( + (type) => type.value === listNotificationConfig.data?.webhookAuthType, + ) || webhookAuthTypes[0], + webhookAuthToken: listNotificationConfig.data?.webhookAuthToken, + webhookAuthUsername: listNotificationConfig.data?.webhookAuthUsername, + webhookAuthPassword: listNotificationConfig.data?.webhookAuthPassword, + webhookAuthHeaderName: listNotificationConfig.data?.webhookAuthHeaderName, + webhookAuthHeaderValue: listNotificationConfig.data?.webhookAuthHeaderValue, + webhookAuthHeaders: listNotificationConfig.data?.webhookAuthHeaders, }); } - }, [listNotificationConfig.isSuccess]); + }, [listNotificationConfig.isSuccess, listNotificationConfig.dataUpdatedAt]); return ( <> @@ -99,6 +119,117 @@ export const CippNotificationForm = ({ helperText="Enter the webhook URL to send notifications to. The URL should be configured to receive a POST request." /> + + + + + + + + + + + <> + + + + + + + + + + + <> + + + + + + + + + + + + + + Send Test Alert @@ -194,6 +325,7 @@ export const CippNotificationForm = ({ type: "POST", dataFunction: (row) => ({ ...row, + tenantFilter: currentTenant, text: "This is a test from Notification Settings", }), }} From ff1f20537c7e5a494ca37f3a42e57380319916cc Mon Sep 17 00:00:00 2001 From: James Tarran Date: Wed, 25 Mar 2026 10:43:29 +0000 Subject: [PATCH 26/35] Update licenses.js --- src/pages/cipp/settings/licenses.js | 83 ++++++++++++++++------------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/src/pages/cipp/settings/licenses.js b/src/pages/cipp/settings/licenses.js index b785792fc97f..d734f7eac437 100644 --- a/src/pages/cipp/settings/licenses.js +++ b/src/pages/cipp/settings/licenses.js @@ -9,9 +9,9 @@ import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog" import { useDialog } from "../../../hooks/use-dialog"; import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; import { CippFormCondition } from "../../../components/CippComponents/CippFormCondition"; -import { ApiGetCall } from "../../../api/ApiCall"; -import M365Licenses from "../../../data/M365Licenses.json"; -import { useMemo } from "react"; +import M365LicensesDefault from "../../../data/M365Licenses.json"; +import M365LicensesAdditional from "../../../data/M365Licenses-additional.json"; +import { useMemo, useCallback } from "react"; const Page = () => { const pageTitle = "Excluded Licenses"; @@ -20,28 +20,33 @@ const Page = () => { const resetDialog = useDialog(); const simpleColumns = ["Product_Display_Name", "GUID"]; - const excludedLicenses = ApiGetCall({ - url: "/api/ListExcludedLicenses", - queryKey: "ExcludedLicenses", - }); - - const licenseOptions = useMemo(() => { - const excludedGuids = new Set( - excludedLicenses.data?.Results?.map((license) => license.GUID) || [] - ); + const allLicenseOptions = useMemo(() => { + const allLicenses = [...M365LicensesDefault, ...M365LicensesAdditional]; const uniqueLicenses = new Map(); - M365Licenses.forEach((license) => { - if (!uniqueLicenses.has(license.GUID) && !excludedGuids.has(license.GUID)) { - uniqueLicenses.set(license.GUID, { - label: license.Product_Display_Name, - value: license.GUID, - }); + + allLicenses.forEach((license) => { + if (license.GUID && license.Product_Display_Name) { + if (!uniqueLicenses.has(license.GUID)) { + uniqueLicenses.set(license.GUID, { + label: license.Product_Display_Name, + value: license.GUID, + }); + } } }); - return Array.from(uniqueLicenses.values()).sort((a, b) => - a.label.localeCompare(b.label) - ); - }, [excludedLicenses.data]); + + const options = Array.from(uniqueLicenses.values()); + const nameCounts = {}; + options.forEach((opt) => { + nameCounts[opt.label] = (nameCounts[opt.label] || 0) + 1; + }); + + return options + .map((opt) => + nameCounts[opt.label] > 1 ? { ...opt, label: `${opt.label} (${opt.value})` } : opt + ) + .sort((a, b) => a.label.localeCompare(b.label)); + }, []); const actions = [ { @@ -93,6 +98,21 @@ const Page = () => { actions: actions, }; + const addExclusionFormatter = useCallback((row, action, formData) => { + if (formData.advancedMode) { + return { + Action: "AddExclusion", + GUID: formData.GUID, + SKUName: formData.SKUName, + }; + } + return { + Action: "AddExclusion", + GUID: formData.selectedLicense?.value, + SKUName: formData.selectedLicense?.label, + }; + }, []); + return ( <> { data: { Action: "!AddExclusion" }, replacementBehaviour: "removeNulls", relatedQueryKeys: ["ExcludedLicenses"], - customDataformatter: (row, action, formData) => { - if (formData.advancedMode) { - return { - Action: "AddExclusion", - GUID: formData.GUID, - SKUName: formData.SKUName, - }; - } else { - return { - Action: "AddExclusion", - GUID: formData.selectedLicense?.value, - SKUName: formData.selectedLicense?.label, - }; - } - }, + customDataformatter: addExclusionFormatter, }} > {({ formHook }) => ( @@ -155,9 +161,10 @@ const Page = () => { type="autoComplete" name="selectedLicense" label="Select License" - options={licenseOptions} + options={allLicenseOptions} formControl={formHook} multiple={false} + creatable={false} validators={{ required: "Please select a license" }} /> From 5d217dc6419b85e04851ef5fabcedfe9687f5f1b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:04:10 +0800 Subject: [PATCH 27/35] Integrate Universal Search dialog in TopNav Add a Universal Search dialog to the top navigation and wire up CippUniversalSearchV2. This change introduces a button and Ctrl/Cmd+Shift+K keyboard shortcut to open the dialog, passes autoFocus to the search component, and resets the component via a key increment on close. CippUniversalSearchV2 was updated to support autoFocus, use placeholder instead of label, clear its input and call onConfirm when items are selected, and adjust internal layout alignment. The standalone Universal Search card was removed from the dashboard page. --- .../CippCards/CippUniversalSearchV2.jsx | 11 ++- src/layouts/top-nav.js | 76 ++++++++++++++++++- src/pages/dashboardv2/index.js | 8 -- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index 0f0d6e88ad8b..27495a690cb2 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -20,7 +20,7 @@ import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippBitlockerKeySearch } from "../CippComponents/CippBitlockerKeySearch"; export const CippUniversalSearchV2 = React.forwardRef( - ({ onConfirm = () => {}, onChange = () => {}, maxResults = 10, value = "" }, ref) => { + ({ onConfirm = () => {}, onChange = () => {}, maxResults = 10, value = "", autoFocus = false }, ref) => { const [searchValue, setSearchValue] = useState(value); const [searchType, setSearchType] = useState("Users"); const [bitlockerLookupType, setBitlockerLookupType] = useState("keyId"); @@ -104,7 +104,9 @@ export const CippUniversalSearchV2 = React.forwardRef( `/identity/administration/groups/group?groupId=${itemData.id}&tenantFilter=${tenantDomain}`, ); } + setSearchValue(""); setShowDropdown(false); + onConfirm(match); }; const handleTypeChange = (type) => { @@ -124,7 +126,9 @@ export const CippUniversalSearchV2 = React.forwardRef( searchType: bitlockerLookupType, }); setBitlockerDrawerVisible(true); + setSearchValue(""); setShowDropdown(false); + onConfirm(match); }; const typeMenuActions = [ @@ -216,7 +220,7 @@ export const CippUniversalSearchV2 = React.forwardRef( return ( <> - + { const searchDialog = useDialog(); + const universalSearchDialog = useDialog(); const { onNavOpen } = props; const settings = useSettings(); const { bookmarks, setBookmarks } = useUserBookmarks(); @@ -64,6 +70,7 @@ export const TopNav = (props) => { const [animatingPair, setAnimatingPair] = useState(null); const [flashSort, setFlashSort] = useState(false); const [flashLock, setFlashLock] = useState(false); + const [universalSearchKey, setUniversalSearchKey] = useState(0); const itemRefs = useRef({}); const touchDragRef = useRef({ startIdx: null, overIdx: null }); const tenantSelectorRef = useRef(null); @@ -198,11 +205,23 @@ export const TopNav = (props) => { searchDialog.handleOpen(); }, [searchDialog.handleOpen]); + const openUniversalSearch = useCallback(() => { + universalSearchDialog.handleOpen(); + }, [universalSearchDialog.handleOpen]); + + const closeUniversalSearch = useCallback(() => { + universalSearchDialog.handleClose(); + setUniversalSearchKey((prev) => prev + 1); + }, [universalSearchDialog.handleClose]); + useEffect(() => { const handleKeyDown = (event) => { if ((event.metaKey || event.ctrlKey) && event.altKey && event.key === "k") { event.preventDefault(); tenantSelectorRef.current?.focus(); + } else if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === "K") { + event.preventDefault(); + openUniversalSearch(); } else if ((event.metaKey || event.ctrlKey) && event.key === "k") { event.preventDefault(); openSearch(); @@ -212,7 +231,7 @@ export const TopNav = (props) => { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [openSearch]); + }, [openSearch, openUniversalSearch]); return ( { { )} + {!mdDown && ( + + + + )} {!mdDown && ( @@ -570,6 +618,32 @@ export const TopNav = (props) => { )} + + Universal Search + + + + + + { return ( - {/* Universal Search */} - - - - - - From fe399a6d65ad38aa8a13a8905e30e2ad369d13ff Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:20:15 +0800 Subject: [PATCH 28/35] Add username space handling and replacement Allow username templates to control how spaces are handled when generating usernames. CippAddEditUser.generateUsername now accepts spaceHandling (keep|remove|replace) and spaceReplacement parameters and applies them before lowercasing. The user defaults page adds two new fields: usernameSpaceHandling (autoComplete with Keep/Remove/Replace) and usernameSpaceReplacement (textField), and includes these keys in the saved/default field lists. This enables admins to configure whether generated usernames keep, remove, or replace spaces (with a custom character). --- .../CippFormPages/CippAddEditUser.jsx | 31 ++++++++++++++++++- src/pages/tenant/manage/user-defaults.js | 22 +++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index b479cacd197a..e24c944acd97 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -86,7 +86,13 @@ const CippAddEditUser = (props) => { const watcher = useWatch({ control: formControl.control }); // Helper function to generate username from template format - const generateUsername = (format, firstName, lastName) => { + const generateUsername = ( + format, + firstName, + lastName, + spaceHandling = "keep", + spaceReplacement = "", + ) => { if (!format || !firstName || !lastName) return ""; // Ensure format is a string @@ -108,6 +114,13 @@ const CippAddEditUser = (props) => { username = username.replace(/%FirstName%/gi, firstName); username = username.replace(/%LastName%/gi, lastName); + // Apply optional space handling + if (spaceHandling === "remove") { + username = username.replace(/\s+/g, ""); + } else if (spaceHandling === "replace") { + username = username.replace(/\s+/g, spaceReplacement || ""); + } + // Convert to lowercase return username.toLowerCase(); }; @@ -152,10 +165,26 @@ const CippAddEditUser = (props) => { : selectedTemplate.usernameFormat?.value || selectedTemplate.usernameFormat?.label; if (formatString) { + const spaceHandling = + typeof selectedTemplate.usernameSpaceHandling === "string" + ? selectedTemplate.usernameSpaceHandling + : selectedTemplate.usernameSpaceHandling?.value || + selectedTemplate.usernameSpaceHandling?.label || + "keep"; + + const spaceReplacement = + typeof selectedTemplate.usernameSpaceReplacement === "string" + ? selectedTemplate.usernameSpaceReplacement + : selectedTemplate.usernameSpaceReplacement?.value || + selectedTemplate.usernameSpaceReplacement?.label || + ""; + const generatedUsername = generateUsername( formatString, watcher.givenName, watcher.surname, + spaceHandling, + spaceReplacement, ); if (generatedUsername) { formControl.setValue("username", generatedUsername, { shouldDirty: true }); diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 08979dd2ed50..62395554d31d 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -56,6 +56,25 @@ const Page = () => { multiple: false, creatable: true, }, + { + label: "Username Space Handling", + name: "usernameSpaceHandling", + type: "autoComplete", + options: [ + { label: "Keep spaces", value: "keep" }, + { label: "Remove spaces", value: "remove" }, + { label: "Replace spaces", value: "replace" }, + ], + helperText: "How spaces in the generated username should be handled.", + multiple: false, + creatable: false, + }, + { + label: "Username Space Replacement", + name: "usernameSpaceReplacement", + type: "textField", + helperText: "Used when space handling is set to Replace spaces (example: _ or .).", + }, { label: "Primary Domain", name: "primDomain", @@ -184,6 +203,8 @@ const Page = () => { "defaultForTenant", "displayName", "usernameFormat", + "usernameSpaceHandling", + "usernameSpaceReplacement", "primDomain", "usageLocation", "licenses", @@ -222,6 +243,7 @@ const Page = () => { "defaultForTenant", "displayName", "usernameFormat", + "usernameSpaceHandling", "usageLocation", "department", ]} From 016ae9ba42f2672966fb1b2e748724b86068b6d8 Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Wed, 25 Mar 2026 13:12:28 +0100 Subject: [PATCH 29/35] Replace ListOrg calls with ListGraphRequest --- src/components/ExecutiveReportButton.js | 18 ++++++++++-------- src/pages/dashboardv1.js | 22 ++++++++++++---------- src/pages/dashboardv2/index.js | 12 +++++++----- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/components/ExecutiveReportButton.js b/src/components/ExecutiveReportButton.js index e25d22d56b11..0e8e67064886 100644 --- a/src/components/ExecutiveReportButton.js +++ b/src/components/ExecutiveReportButton.js @@ -2709,12 +2709,14 @@ export const ExecutiveReportButton = (props) => { // Fetch organization data - only when preview is open const organization = ApiGetCall({ - url: "/api/ListOrg", - queryKey: `${settings.currentTenant}-ListOrg-report`, - data: { tenantFilter: settings.currentTenant }, + url: "/api/ListGraphRequest", + queryKey: `${settings.currentTenant}-ListGraphRequest-organization-report`, + data: { tenantFilter: settings.currentTenant, Endpoint: "organization" }, waiting: previewOpen, }); + const organizationRecord = organization.data?.Results?.[0]; + // Fetch user counts - only when preview is open const dashboard = ApiGetCall({ url: "/api/ListuserCounts", @@ -2812,8 +2814,8 @@ export const ExecutiveReportButton = (props) => { // Button is always available now since we don't need to wait for data const shouldShowButton = true; - const tenantName = organization.data?.displayName || "Tenant"; - const tenantId = organization.data?.id; + const tenantName = organizationRecord?.displayName || "Tenant"; + const tenantId = organizationRecord?.id; const userStats = { licensedUsers: dashboard.data?.LicUsers || 0, unlicensedUsers: @@ -2855,7 +2857,7 @@ export const ExecutiveReportButton = (props) => { tenantId={tenantId} userStats={userStats} standardsData={driftComplianceData.data} - organizationData={organization.data} + organizationData={organizationRecord} brandingSettings={brandingSettings} secureScoreData={secureScore.isSuccess ? secureScore : null} licensingData={licenseData.isSuccess ? licenseData?.data : null} @@ -2889,7 +2891,7 @@ export const ExecutiveReportButton = (props) => { tenantName, tenantId, userStats, - organization.data, + organizationRecord, dashboard.data, brandingSettings, secureScore?.isSuccess, @@ -3205,7 +3207,7 @@ export const ExecutiveReportButton = (props) => { tenantId={tenantId} userStats={userStats} standardsData={driftComplianceData.data} - organizationData={organization.data} + organizationData={organizationRecord} brandingSettings={brandingSettings} secureScoreData={secureScore.isSuccess ? secureScore : null} licensingData={licenseData.isSuccess ? licenseData?.data : null} diff --git a/src/pages/dashboardv1.js b/src/pages/dashboardv1.js index a2054d962e5e..8e45642518cd 100644 --- a/src/pages/dashboardv1.js +++ b/src/pages/dashboardv1.js @@ -21,11 +21,13 @@ const Page = () => { const [domainVisible, setDomainVisible] = useState(false); const organization = ApiGetCall({ - url: "/api/ListOrg", - queryKey: `${currentTenant}-ListOrg`, - data: { tenantFilter: currentTenant }, + url: "/api/ListGraphRequest", + queryKey: `${currentTenant}-ListGraphRequest-organization`, + data: { tenantFilter: currentTenant, Endpoint: "organization" }, }); + const organizationRecord = organization.data?.Results?.[0]; + const dashboard = ApiGetCall({ url: "/api/ListuserCounts", data: { tenantFilter: currentTenant }, @@ -68,12 +70,12 @@ const Page = () => { // Top bar data const tenantInfo = [ - { name: "Tenant Name", data: organization.data?.displayName }, + { name: "Tenant Name", data: organizationRecord?.displayName }, { name: "Tenant ID", data: ( <> - + ), }, @@ -83,7 +85,7 @@ const Page = () => { <> domain.isDefault === true)?.name + organizationRecord?.verifiedDomains?.find((domain) => domain.isDefault === true)?.name } type="chip" /> @@ -92,7 +94,7 @@ const Page = () => { }, { name: "AD Sync Enabled", - data: getCippFormatting(organization.data?.onPremisesSyncEnabled, "dirsync"), + data: getCippFormatting(organizationRecord?.onPremisesSyncEnabled, "dirsync"), }, ]; @@ -369,14 +371,14 @@ const Page = () => { showDivider={false} copyItems={true} isFetching={organization.isFetching} - propertyItems={organization.data?.verifiedDomains + propertyItems={organizationRecord?.verifiedDomains ?.slice(0, domainVisible ? undefined : 3) .map((domain, idx) => ({ label: "", value: domain.name, }))} actionButton={ - organization.data?.verifiedDomains?.length > 3 && ( + organizationRecord?.verifiedDomains?.length > 3 && ( @@ -417,7 +419,7 @@ const Page = () => { propertyItems={[ { label: "Services", - value: organization.data?.assignedPlans + value: organizationRecord?.assignedPlans ?.filter( (plan) => plan.capabilityStatus === "Enabled" && diff --git a/src/pages/dashboardv2/index.js b/src/pages/dashboardv2/index.js index fcb983b4e36d..c5c4283ce475 100644 --- a/src/pages/dashboardv2/index.js +++ b/src/pages/dashboardv2/index.js @@ -87,11 +87,13 @@ const Page = () => { }, [reportIdValue]); const organization = ApiGetCall({ - url: "/api/ListOrg", - queryKey: `${currentTenant}-ListOrg`, - data: { tenantFilter: currentTenant }, + url: "/api/ListGraphRequest", + queryKey: `${currentTenant}-ListGraphRequest-organization`, + data: { tenantFilter: currentTenant, Endpoint: "organization" }, }); + const organizationRecord = organization.data?.Results?.[0]; + const testsApi = ApiGetCall({ url: "/api/ListTests", data: { tenantFilter: currentTenant, reportId: selectedReport }, @@ -112,7 +114,7 @@ const Page = () => { testsApi.isSuccess && testsApi.data?.TenantCounts ? { ExecutedAt: testsApi.data?.LatestReportTimeStamp || null, - TenantName: organization.data?.displayName || "", + TenantName: organizationRecord?.displayName || "", Domain: currentTenant || "", TestResultSummary: { IdentityPassed: testsApi.data.TestCounts?.Identity?.Passed || 0, @@ -326,7 +328,7 @@ const Page = () => { {/* Column 1: Tenant Information */} - + {/* Column 2: Tenant Metrics - 2x3 Grid */} From 6e6a1dc2ee02ec4c938c173363cb42faccf47577 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:16:42 +0800 Subject: [PATCH 30/35] Update top-nav.js --- src/layouts/top-nav.js | 38 ++++++++++---------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index f8b47ed7bd44..917fffbbe275 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -5,6 +5,7 @@ import Bars3Icon from "@heroicons/react/24/outline/Bars3Icon"; import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; import SunIcon from "@heroicons/react/24/outline/SunIcon"; import BookmarkIcon from "@mui/icons-material/Bookmark"; +import TravelExploreIcon from "@mui/icons-material/TravelExplore"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; @@ -16,7 +17,6 @@ import LockIcon from "@mui/icons-material/Lock"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import { Box, - Button, Divider, Dialog, DialogContent, @@ -30,6 +30,7 @@ import { ListItem, ListItemText, Typography, + TravelExplore, } from "@mui/material"; import { Logo } from "../components/logo"; import { useSettings } from "../hooks/use-settings"; @@ -289,35 +290,16 @@ export const TopNav = (props) => { )} - {!mdDown && ( - - - - )} - + + + )} {!mdDown && ( From 95083b65702f3d19b4d35e2719f25624fcede52b Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Wed, 25 Mar 2026 13:27:49 +0100 Subject: [PATCH 31/35] Replace ListDevices with ListGraphRequest --- src/components/ExecutiveReportButton.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/ExecutiveReportButton.js b/src/components/ExecutiveReportButton.js index 0e8e67064886..64d2609015af 100644 --- a/src/components/ExecutiveReportButton.js +++ b/src/components/ExecutiveReportButton.js @@ -2740,11 +2740,12 @@ export const ExecutiveReportButton = (props) => { // Get real device data - only when preview is open const deviceData = ApiGetCall({ - url: "/api/ListDevices", + url: "/api/ListGraphRequest", data: { tenantFilter: settings.currentTenant, + Endpoint: "deviceManagement/managedDevices", }, - queryKey: `devices-report-${settings.currentTenant}`, + queryKey: `ListGraphRequest-devices-report-${settings.currentTenant}`, waiting: previewOpen, }); @@ -2861,7 +2862,7 @@ export const ExecutiveReportButton = (props) => { brandingSettings={brandingSettings} secureScoreData={secureScore.isSuccess ? secureScore : null} licensingData={licenseData.isSuccess ? licenseData?.data : null} - deviceData={deviceData.isSuccess ? deviceData?.data : null} + deviceData={deviceData.isSuccess ? deviceData?.data?.Results : null} conditionalAccessData={ conditionalAccessData.isSuccess ? conditionalAccessData?.data?.Results : null } @@ -3211,7 +3212,7 @@ export const ExecutiveReportButton = (props) => { brandingSettings={brandingSettings} secureScoreData={secureScore.isSuccess ? secureScore : null} licensingData={licenseData.isSuccess ? licenseData?.data : null} - deviceData={deviceData.isSuccess ? deviceData?.data : null} + deviceData={deviceData.isSuccess ? deviceData?.data?.Results : null} conditionalAccessData={ conditionalAccessData.isSuccess ? conditionalAccessData?.data?.Results : null } From d53b80dadc7613c8e1e829079de3433cda49ff6f Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Wed, 25 Mar 2026 13:38:06 +0100 Subject: [PATCH 32/35] Replace ListDomains with ListGraphRequest --- src/pages/tenant/manage/user-defaults.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/tenant/manage/user-defaults.js b/src/pages/tenant/manage/user-defaults.js index 62395554d31d..8eb3a3471455 100644 --- a/src/pages/tenant/manage/user-defaults.js +++ b/src/pages/tenant/manage/user-defaults.js @@ -80,10 +80,14 @@ const Page = () => { name: "primDomain", type: "autoComplete", api: { - url: "/api/ListDomains", + url: "/api/ListGraphRequest", + dataKey: "Results", + data: { + Endpoint: "domains", + }, labelField: "id", valueField: "id", - queryKey: "ListDomains", + queryKey: "ListGraphRequest-domains", }, multiple: false, creatable: false, From 4b9e78c0441afe28a995a9da5956f083e64a454e Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Wed, 25 Mar 2026 15:07:00 +0100 Subject: [PATCH 33/35] Add alerts to frontend --- src/data/alerts.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/data/alerts.json b/src/data/alerts.json index 7e93eb047e8b..77341e1c0b89 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -324,6 +324,16 @@ "label": "Alert on expiring application certificates", "recommendedRunInterval": "1d" }, + { + "name": "LongLivedAppCredentials", + "label": "Alert on long-lived app registration secrets or certificates", + "recommendedRunInterval": "1d", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Maximum allowed credential lifetime in months (1-24)", + "inputName": "InputValue", + "description": "Checks app registrations for client secrets or certificates that are valid longer than the Microsoft UI currently allows. Set a threshold from 1 to 24 months." + }, { "name": "ApnCertExpiry", "label": "Alert on expiring APN certificates", @@ -394,6 +404,12 @@ "label": "Alert on licensed users with any administrator roles", "recommendedRunInterval": "7d" }, + { + "name": "RoleEscalableGroups", + "label": "Alert on groups or nested groups assigned to a role that could be used for privilege escalation", + "recommendedRunInterval": "1d", + "description": "Scans for groups, including nested groups, that are assigned to directory roles and are not role assignable, which allows group owners or group admins to add more users to the group and gain access to a potentially privileged role." + }, { "name": "HuntressRogueApps", "label": "Alert on Huntress Rogue Apps detected", From 558dc97569bd03c5c1307c91c030dbf84c50e778 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:12:00 +0800 Subject: [PATCH 34/35] Pages search & keyboard nav; remove central search Extend CippUniversalSearchV2 to support a new "Pages" search mode and improved keyboard navigation. Adds dynamic tabOptions loading, menu flattening, breadcrumb building and permission/role filtering so pages and tabs (including tabOptions.json entries) appear in search results. Implements arrow/Enter/Escape navigation, result highlighting/scroll-into-view, and UI tweaks (hide Search button for Pages, loading states). Update top-nav to open the universal search in different default modes (Ctrl/Cmd+K for Pages, Ctrl/Cmd+Shift+K for Users), add the magnifying icon, and pass defaultSearchType into the dialog. Remove the legacy CippCentralSearch component file. --- .../CippCards/CippUniversalSearchV2.jsx | 456 +++++++++++++++++- .../CippComponents/CippCentralSearch.jsx | 392 --------------- src/layouts/top-nav.js | 39 +- 3 files changed, 456 insertions(+), 431 deletions(-) delete mode 100644 src/components/CippComponents/CippCentralSearch.jsx diff --git a/src/components/CippCards/CippUniversalSearchV2.jsx b/src/components/CippCards/CippUniversalSearchV2.jsx index 27495a690cb2..caafb71bab40 100644 --- a/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/src/components/CippCards/CippUniversalSearchV2.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useMemo } from "react"; import { TextField, Box, @@ -18,22 +18,123 @@ import { useRouter } from "next/router"; import { BulkActionsMenu } from "../bulk-actions-menu"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippBitlockerKeySearch } from "../CippComponents/CippBitlockerKeySearch"; +import { nativeMenuItems } from "../../layouts/config"; +import { usePermissions } from "../../hooks/use-permissions"; + +function getLeafItems(items = []) { + let result = []; + + for (const item of items) { + if (item.items && Array.isArray(item.items) && item.items.length > 0) { + result = result.concat(getLeafItems(item.items)); + } else { + result.push(item); + } + } + + return result; +} + +async function loadTabOptions() { + const tabOptionPaths = [ + "/email/administration/exchange-retention", + "/cipp/custom-data", + "/cipp/super-admin", + "/tenant/standards", + "/tenant/manage", + "/tenant/administration/applications", + "/tenant/administration/tenants", + "/tenant/administration/audit-logs", + "/identity/administration/users/user", + "/tenant/administration/securescore", + "/tenant/gdap-management", + "/tenant/gdap-management/relationships/relationship", + "/cipp/settings", + ]; + + const tabOptions = []; + + for (const basePath of tabOptionPaths) { + try { + const module = await import(`../../pages${basePath}/tabOptions.json`); + const options = module.default || module; + + options.forEach((option) => { + tabOptions.push({ + title: option.label, + path: option.path, + type: "tab", + basePath, + }); + }); + } catch (error) { + console.debug(`Could not load tabOptions for ${basePath}:`, error); + } + } + + return tabOptions; +} + +function filterItemsByPermissionsAndRoles(items, userPermissions, userRoles) { + return items.filter((item) => { + if (item.permissions && item.permissions.length > 0) { + const hasPermission = userPermissions?.some((userPerm) => { + return item.permissions.some((requiredPerm) => { + if (userPerm === requiredPerm) { + return true; + } + + if (requiredPerm.includes("*")) { + const regexPattern = requiredPerm + .replace(/\\/g, "\\\\") + .replace(/\./g, "\\.") + .replace(/\*/g, ".*"); + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(userPerm); + } + + return false; + }); + }); + if (!hasPermission) { + return false; + } + } + + return true; + }); +} export const CippUniversalSearchV2 = React.forwardRef( - ({ onConfirm = () => {}, onChange = () => {}, maxResults = 10, value = "", autoFocus = false }, ref) => { + ( + { + onConfirm = () => {}, + onChange = () => {}, + maxResults = 10, + value = "", + autoFocus = false, + defaultSearchType = "Users", + }, + ref, + ) => { const [searchValue, setSearchValue] = useState(value); - const [searchType, setSearchType] = useState("Users"); + const [searchType, setSearchType] = useState(defaultSearchType); const [bitlockerLookupType, setBitlockerLookupType] = useState("keyId"); + const [tabOptions, setTabOptions] = useState([]); const [showDropdown, setShowDropdown] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); const [bitlockerDrawerVisible, setBitlockerDrawerVisible] = useState(false); const [bitlockerDrawerDefaults, setBitlockerDrawerDefaults] = useState({ searchTerm: "", searchType: "keyId", }); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); + const [dropdownMaxHeight, setDropdownMaxHeight] = useState(400); const containerRef = useRef(null); const textFieldRef = useRef(null); + const dropdownRef = useRef(null); const router = useRouter(); + const { userPermissions, userRoles } = usePermissions(); const universalSearch = ApiGetCall({ url: `/api/ExecUniversalSearchV2`, @@ -55,7 +156,110 @@ export const CippUniversalSearchV2 = React.forwardRef( waiting: false, }); - const activeSearch = searchType === "BitLocker" ? bitlockerSearch : universalSearch; + const activeSearch = + searchType === "BitLocker" ? bitlockerSearch : searchType === "Pages" ? null : universalSearch; + + const flattenedMenuItems = useMemo(() => { + const allLeafItems = getLeafItems(nativeMenuItems); + + const buildBreadcrumbPath = (items, targetPath) => { + const searchRecursive = (nestedItems, currentPath = []) => { + for (const item of nestedItems) { + const shouldAddToPath = item.title !== "Dashboard" || item.path !== "/"; + const newPath = shouldAddToPath ? [...currentPath, item.title] : currentPath; + + if (item.path) { + const normalizedItemPath = item.path.replace(/\/$/, ""); + const normalizedTargetPath = targetPath.replace(/\/$/, ""); + + if (normalizedItemPath !== "/" && normalizedItemPath.startsWith(normalizedTargetPath)) { + return newPath; + } + } + + if (item.items && item.items.length > 0) { + const childResult = searchRecursive(item.items, newPath); + if (childResult.length > 0) { + return childResult; + } + } + } + return []; + }; + + return searchRecursive(items); + }; + + const filteredMainMenu = filterItemsByPermissionsAndRoles( + allLeafItems, + userPermissions, + userRoles, + ).map((item) => { + const rawBreadcrumbs = buildBreadcrumbPath(nativeMenuItems, item.path) || []; + const trimmedBreadcrumbs = + rawBreadcrumbs.length > 0 && rawBreadcrumbs[rawBreadcrumbs.length - 1] === item.title + ? rawBreadcrumbs.slice(0, -1) + : rawBreadcrumbs; + return { + ...item, + breadcrumbs: trimmedBreadcrumbs, + }; + }); + + const leafItemIndex = allLeafItems.reduce((acc, item) => { + if (item.path) { + acc[item.path.replace(/\/$/, "")] = item; + } + return acc; + }, {}); + + const filteredTabOptions = tabOptions + .map((tab) => { + const normalizedTabPath = tab.path.replace(/\/$/, ""); + const normalizedBasePath = tab.basePath?.replace(/\/$/, ""); + + let pageItem = leafItemIndex[normalizedTabPath]; + + if (!pageItem && normalizedBasePath) { + pageItem = allLeafItems.find((item) => { + const normalizedItemPath = item.path?.replace(/\/$/, ""); + return normalizedItemPath && normalizedItemPath.startsWith(normalizedBasePath); + }); + } + + if (!pageItem) return null; + + const hasAccessToPage = + filterItemsByPermissionsAndRoles([pageItem], userPermissions, userRoles).length > 0; + if (!hasAccessToPage) return null; + + const breadcrumbs = buildBreadcrumbPath(nativeMenuItems, pageItem.path) || []; + const trimmedBreadcrumbs = + breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1] === tab.title + ? breadcrumbs.slice(0, -1) + : breadcrumbs; + + return { + ...tab, + breadcrumbs: trimmedBreadcrumbs, + }; + }) + .filter(Boolean); + + return [...filteredMainMenu, ...filteredTabOptions]; + }, [userPermissions, userRoles, tabOptions]); + + const normalizedSearch = searchValue.trim().toLowerCase(); + const pageResults = flattenedMenuItems.filter((leaf) => { + const inTitle = leaf.title?.toLowerCase().includes(normalizedSearch); + const inPath = leaf.path?.toLowerCase().includes(normalizedSearch); + const inBreadcrumbs = leaf.breadcrumbs?.some((crumb) => + crumb?.toLowerCase().includes(normalizedSearch), + ); + const inScope = (leaf.scope === "global" ? "global" : "tenant").includes(normalizedSearch); + + return normalizedSearch ? inTitle || inPath || inBreadcrumbs || inScope : false; + }); const handleChange = (event) => { const newValue = event.target.value; @@ -64,21 +268,60 @@ export const CippUniversalSearchV2 = React.forwardRef( if (newValue.length === 0) { setShowDropdown(false); + } else if (searchType === "Pages") { + updateDropdownPosition(); + setShowDropdown(true); } }; const updateDropdownPosition = () => { if (textFieldRef.current) { const rect = textFieldRef.current.getBoundingClientRect(); + const availableHeight = Math.max(220, window.innerHeight - rect.bottom - 16); setDropdownPosition({ top: rect.bottom + window.scrollY + 4, left: rect.left + window.scrollX, width: rect.width, }); + setDropdownMaxHeight(availableHeight); } }; const handleKeyDown = (event) => { + if (event.key === "Escape" && showDropdown) { + event.preventDefault(); + setShowDropdown(false); + setHighlightedIndex(-1); + return; + } + + if ((event.key === "ArrowDown" || event.key === "ArrowUp") && showDropdown && hasResults) { + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + const total = activeResults.length; + setHighlightedIndex((prev) => { + if (prev < 0) { + return direction === 1 ? 0 : total - 1; + } + return (prev + direction + total) % total; + }); + return; + } + + if (event.key === "Enter" && showDropdown && hasResults && highlightedIndex >= 0) { + event.preventDefault(); + const selectedItem = activeResults[highlightedIndex]; + if (!selectedItem) { + return; + } + if (searchType === "BitLocker") { + handleBitlockerResultClick(selectedItem); + } else { + handleResultClick(selectedItem); + } + return; + } + if (event.key === "Enter" && searchValue.length > 0) { handleSearch(); } @@ -87,7 +330,9 @@ export const CippUniversalSearchV2 = React.forwardRef( const handleSearch = () => { if (searchValue.length > 0) { updateDropdownPosition(); - activeSearch.refetch(); + if (searchType !== "Pages") { + activeSearch?.refetch(); + } setShowDropdown(true); } }; @@ -103,6 +348,8 @@ export const CippUniversalSearchV2 = React.forwardRef( router.push( `/identity/administration/groups/group?groupId=${itemData.id}&tenantFilter=${tenantDomain}`, ); + } else if (searchType === "Pages") { + router.push(match.path, undefined, { shallow: true }); } setSearchValue(""); setShowDropdown(false); @@ -147,6 +394,11 @@ export const CippUniversalSearchV2 = React.forwardRef( icon: "FilePresent", onClick: () => handleTypeChange("BitLocker"), }, + { + label: "Pages", + icon: "GlobeAltIcon", + onClick: () => handleTypeChange("Pages"), + }, ]; const bitlockerLookupActions = [ @@ -197,12 +449,51 @@ export const CippUniversalSearchV2 = React.forwardRef( } }, [showDropdown]); + useEffect(() => { + setHighlightedIndex(-1); + }, [searchType, searchValue, showDropdown]); + + useEffect(() => { + if (!showDropdown || highlightedIndex < 0 || !dropdownRef.current) { + return; + } + + const activeRow = dropdownRef.current.querySelector( + `[data-result-index="${highlightedIndex}"]`, + ); + + if (activeRow && typeof activeRow.scrollIntoView === "function") { + activeRow.scrollIntoView({ block: "nearest" }); + } + }, [highlightedIndex, showDropdown]); + + useEffect(() => { + loadTabOptions().then(setTabOptions); + }, []); + + useEffect(() => { + setSearchType(defaultSearchType); + if (defaultSearchType === "BitLocker") { + setBitlockerLookupType("keyId"); + } + }, [defaultSearchType]); + const bitlockerResults = Array.isArray(bitlockerSearch?.data?.Results) ? bitlockerSearch.data.Results : []; const universalResults = Array.isArray(universalSearch?.data) ? universalSearch.data : []; + const activeResults = + searchType === "BitLocker" + ? bitlockerResults + : searchType === "Pages" + ? pageResults + : universalResults; const hasResults = - searchType === "BitLocker" ? bitlockerResults.length > 0 : universalResults.length > 0; + searchType === "BitLocker" + ? bitlockerResults.length > 0 + : searchType === "Pages" + ? pageResults.length > 0 + : universalResults.length > 0; const shouldShowDropdown = showDropdown && searchValue.length > 0; const getLabel = () => { @@ -214,6 +505,8 @@ export const CippUniversalSearchV2 = React.forwardRef( return bitlockerLookupType === "deviceId" ? "Search BitLocker by Device ID" : "Search BitLocker by Recovery Key ID"; + } else if (searchType === "Pages") { + return "Search pages, tabs, paths, or scope"; } return "Search"; }; @@ -256,7 +549,7 @@ export const CippUniversalSearchV2 = React.forwardRef( ), - endAdornment: activeSearch.isFetching ? ( + endAdornment: activeSearch?.isFetching ? ( @@ -269,28 +562,31 @@ export const CippUniversalSearchV2 = React.forwardRef( }, }} /> - + {searchType !== "Pages" && ( + + )} {shouldShowDropdown && ( - {activeSearch.isFetching ? ( + {activeSearch?.isFetching ? ( @@ -308,6 +604,16 @@ export const CippUniversalSearchV2 = React.forwardRef( + ) : searchType === "Pages" ? ( + ) : ( ) ) : ( @@ -348,7 +656,14 @@ export const CippUniversalSearchV2 = React.forwardRef( CippUniversalSearchV2.displayName = "CippUniversalSearchV2"; -const Results = ({ items = [], searchValue, onResultClick, searchType = "Users" }) => { +const Results = ({ + items = [], + searchValue, + onResultClick, + searchType = "Users", + highlightedIndex = -1, + setHighlightedIndex = () => {}, +}) => { const highlightMatch = (text) => { if (!text || !searchValue) return text; const escapedSearch = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -373,12 +688,16 @@ const Results = ({ items = [], searchValue, onResultClick, searchType = "Users" return ( onResultClick(match)} + onMouseEnter={() => setHighlightedIndex(index)} + selected={highlightedIndex === index} sx={{ py: 1.5, px: 2, borderBottom: index < items.length - 1 ? "1px solid" : "none", borderColor: "divider", + backgroundColor: highlightedIndex === index ? "action.selected" : "transparent", "&:hover": { backgroundColor: "action.hover", }, @@ -428,18 +747,115 @@ const Results = ({ items = [], searchValue, onResultClick, searchType = "Users" ); }; -const BitlockerResults = ({ items = [], onResultClick }) => { +const PageResults = ({ + items = [], + searchValue, + onResultClick, + highlightedIndex = -1, + setHighlightedIndex = () => {}, +}) => { + const highlightMatch = (text = "") => { + if (!text || !searchValue) return text; + const escapedSearch = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = text.split(new RegExp(`(${escapedSearch})`, "gi")); + return parts.map((part, index) => + part.toLowerCase() === searchValue.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ); + }; + + return ( + <> + {items.map((item, index) => { + const isGlobal = item.scope === "global"; + const itemType = item.type === "tab" ? "Tab" : "Page"; + + return ( + onResultClick(item)} + onMouseEnter={() => setHighlightedIndex(index)} + selected={highlightedIndex === index} + sx={{ + py: 1.5, + px: 2, + borderBottom: index < items.length - 1 ? "1px solid" : "none", + borderColor: "divider", + alignItems: "flex-start", + backgroundColor: highlightedIndex === index ? "action.selected" : "transparent", + "&:hover": { + backgroundColor: "action.hover", + }, + }} + > + + + {highlightMatch(item.title || "")} + + + {itemType} + + + {isGlobal ? "Global" : "Tenant"} + + + } + secondary={ + + {item.breadcrumbs && item.breadcrumbs.length > 0 && ( + + {item.breadcrumbs.map((crumb, idx) => ( + + {highlightMatch(crumb)} + {idx < item.breadcrumbs.length - 1 && " > "} + + ))} + {" > "} + {highlightMatch(item.title || "")} + + )} + + Path: {highlightMatch(item.path || "")} + + + } + /> + + ); + })} + + ); +}; + +const BitlockerResults = ({ + items = [], + onResultClick, + highlightedIndex = -1, + setHighlightedIndex = () => {}, +}) => { return ( <> {items.map((result, index) => ( onResultClick(result)} + onMouseEnter={() => setHighlightedIndex(index)} + selected={highlightedIndex === index} sx={{ py: 1.5, px: 2, borderBottom: index < items.length - 1 ? "1px solid" : "none", borderColor: "divider", + backgroundColor: highlightedIndex === index ? "action.selected" : "transparent", "&:hover": { backgroundColor: "action.hover", }, diff --git a/src/components/CippComponents/CippCentralSearch.jsx b/src/components/CippComponents/CippCentralSearch.jsx deleted file mode 100644 index e21849b63133..000000000000 --- a/src/components/CippComponents/CippCentralSearch.jsx +++ /dev/null @@ -1,392 +0,0 @@ -import React, { useState, useMemo, useEffect } from "react"; -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - TextField, - Card, - CardContent, - CardActionArea, - Typography, - Box, -} from "@mui/material"; -import { Grid } from "@mui/system"; -import { useRouter } from "next/router"; -import { nativeMenuItems } from "../../layouts/config"; -import { usePermissions } from "../../hooks/use-permissions"; - -/** - * Recursively collects only leaf items (those without sub-items). - * If an item has an `items` array, we skip that item itself (treat it as a "folder") - * and continue flattening deeper. - */ -function getLeafItems(items = []) { - let result = []; - - for (const item of items) { - if (item.items && Array.isArray(item.items) && item.items.length > 0) { - // recurse into children - result = result.concat(getLeafItems(item.items)); - } else { - // no child items => this is a leaf - result.push(item); - } - } - - return result; -} - -/** - * Load all tabOptions.json files dynamically - */ -async function loadTabOptions() { - const tabOptionPaths = [ - "/email/administration/exchange-retention", - "/cipp/custom-data", - "/cipp/super-admin", - "/tenant/standards", - "/tenant/manage", - "/tenant/administration/applications", - "/tenant/administration/tenants", - "/tenant/administration/audit-logs", - "/identity/administration/users/user", - "/tenant/administration/securescore", - "/tenant/gdap-management", - "/tenant/gdap-management/relationships/relationship", - "/cipp/settings", - ]; - - const tabOptions = []; - - for (const basePath of tabOptionPaths) { - try { - const module = await import(`../../pages${basePath}/tabOptions.json`); - const options = module.default || module; - - // Add each tab option with metadata - options.forEach((option) => { - tabOptions.push({ - title: option.label, - path: option.path, - type: "tab", - basePath: basePath, - }); - }); - } catch (error) { - // Silently skip if file doesn't exist or can't be loaded - console.debug(`Could not load tabOptions for ${basePath}:`, error); - } - } - - return tabOptions; -} - -/** - * Filter menu items based on user permissions and roles - */ -function filterItemsByPermissionsAndRoles(items, userPermissions, userRoles) { - return items.filter((item) => { - // Check permissions with pattern matching support - if (item.permissions && item.permissions.length > 0) { - const hasPermission = userPermissions?.some((userPerm) => { - return item.permissions.some((requiredPerm) => { - // Exact match - if (userPerm === requiredPerm) { - return true; - } - - // Pattern matching - check if required permission contains wildcards - if (requiredPerm.includes("*")) { - // Convert wildcard pattern to regex - const regexPattern = requiredPerm - .replace(/\\/g, "\\\\") // Escape backslashes - .replace(/\./g, "\\.") // Escape dots - .replace(/\*/g, ".*"); // Convert * to .* - const regex = new RegExp(`^${regexPattern}$`); - return regex.test(userPerm); - } - - return false; - }); - }); - if (!hasPermission) { - return false; - } - } - - return true; - }); -} - -export const CippCentralSearch = ({ handleClose, open }) => { - const router = useRouter(); - const [searchValue, setSearchValue] = useState(""); - const { userPermissions, userRoles } = usePermissions(); - const [tabOptions, setTabOptions] = useState([]); - - // Load tab options on mount - useEffect(() => { - loadTabOptions().then(setTabOptions); - }, []); - - // Flatten and filter the menu items based on user permissions - const flattenedMenuItems = useMemo(() => { - const allLeafItems = getLeafItems(nativeMenuItems); - - // Helper to build full breadcrumb path - const buildBreadcrumbPath = (items, targetPath) => { - const searchRecursive = (items, currentPath = []) => { - for (const item of items) { - // Skip Dashboard root - const shouldAddToPath = item.title !== "Dashboard" || item.path !== "/"; - const newPath = shouldAddToPath ? [...currentPath, item.title] : currentPath; - - // Check if this item itself matches - if (item.path) { - const normalizedItemPath = item.path.replace(/\/$/, ""); - const normalizedTargetPath = targetPath.replace(/\/$/, ""); - - // Check if this item's path starts with target (item is under target path) - if (normalizedItemPath !== "/" && normalizedItemPath.startsWith(normalizedTargetPath)) { - // Return the full path - return newPath; - } - } - - // Check if this item's children match (for container items without paths) - if (item.items && item.items.length > 0) { - const childResult = searchRecursive(item.items, newPath); - if (childResult.length > 0) { - return childResult; - } - } - } - return []; - }; - - return searchRecursive(items); - }; - - const filteredMainMenu = filterItemsByPermissionsAndRoles( - allLeafItems, - userPermissions, - userRoles, - ).map((item) => { - const rawBreadcrumbs = buildBreadcrumbPath(nativeMenuItems, item.path) || []; - // Remove the leaf item's own title to avoid duplicate when rendering - const trimmedBreadcrumbs = - rawBreadcrumbs.length > 0 && rawBreadcrumbs[rawBreadcrumbs.length - 1] === item.title - ? rawBreadcrumbs.slice(0, -1) - : rawBreadcrumbs; - return { - ...item, - breadcrumbs: trimmedBreadcrumbs, - }; - }); - - // Index leaf items by path for direct permission lookup - const leafItemIndex = allLeafItems.reduce((acc, item) => { - if (item.path) acc[item.path.replace(/\/$/, "")] = item; - return acc; - }, {}); - - // Filter tab options based on the actual page item's permissions - const filteredTabOptions = tabOptions - .map((tab) => { - const normalizedTabPath = tab.path.replace(/\/$/, ""); - const normalizedBasePath = tab.basePath?.replace(/\/$/, ""); - - // Try exact match first - let pageItem = leafItemIndex[normalizedTabPath]; - - // Fallback: find any menu item whose path starts with the basePath - if (!pageItem && normalizedBasePath) { - pageItem = allLeafItems.find((item) => { - const normalizedItemPath = item.path?.replace(/\/$/, ""); - return normalizedItemPath && normalizedItemPath.startsWith(normalizedBasePath); - }); - } - - if (!pageItem) return null; // No matching page definition - - // Permission/role check using the page item directly - const hasAccessToPage = - filterItemsByPermissionsAndRoles([pageItem], userPermissions, userRoles).length > 0; - if (!hasAccessToPage) return null; - - // Build breadcrumbs using the pageItem's path (which exists in menu tree) - const breadcrumbs = buildBreadcrumbPath(nativeMenuItems, pageItem.path) || []; - // Remove duplicate last crumb if equal to tab title (will be appended during render) - const trimmedBreadcrumbs = - breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1] === tab.title - ? breadcrumbs.slice(0, -1) - : breadcrumbs; - return { - ...tab, - breadcrumbs: trimmedBreadcrumbs, - }; - }) - .filter(Boolean); - - return [...filteredMainMenu, ...filteredTabOptions]; - }, [userPermissions, userRoles, tabOptions]); - - const handleChange = (event) => { - setSearchValue(event.target.value); - }; - - // Optionally handle Enter key - const handleKeyDown = (event) => { - if (event.key === "Enter") { - // do something if needed, e.g., analytics or highlight - } - }; - - // Filter leaf items by matching title, path, or breadcrumbs - const normalizedSearch = searchValue.trim().toLowerCase(); - const filteredItems = flattenedMenuItems.filter((leaf) => { - const inTitle = leaf.title?.toLowerCase().includes(normalizedSearch); - const inPath = leaf.path?.toLowerCase().includes(normalizedSearch); - const inBreadcrumbs = leaf.breadcrumbs?.some((crumb) => - crumb?.toLowerCase().includes(normalizedSearch), - ); - const inScope = (leaf.scope === "global" ? "global" : "tenant").includes(normalizedSearch); - // If there's no search value, show no results (you could change this logic) - return normalizedSearch ? inTitle || inPath || inBreadcrumbs || inScope : false; - }); - - // Helper to bold‐highlight the matched text - const highlightMatch = (text = "") => { - if (!normalizedSearch) return text; - const parts = text.split(new RegExp(`(${normalizedSearch})`, "gi")); - return parts.map((part, i) => - part.toLowerCase() === normalizedSearch ? ( - - {part} - - ) : ( - part - ), - ); - }; - - // Helper to get item type label - const getItemTypeLabel = (item) => { - if (item.type === "tab") { - return "Tab"; - } - return "Page"; - }; - - // Click handler: shallow navigate with Next.js - const handleCardClick = (path) => { - router.push(path, undefined, { shallow: true }); - handleClose(); - }; - - return ( - - CIPP Search - - - - { - // Select all text on focus if there's content - if (event.target.value) { - event.target.select(); - } - }} - value={searchValue} - autoFocus - /> - - {/* Show results or "No results" */} - {searchValue.trim().length > 0 ? ( - filteredItems.length > 0 ? ( - - {filteredItems.map((item, index) => { - const isGlobal = item.scope === "global"; - return ( - - - handleCardClick(item.path)} - aria-label={`Navigate to ${item.title}`} - > - - - {highlightMatch(item.title)} - - {getItemTypeLabel(item)} - - - {isGlobal ? "Global" : "Tenant"} - - - {item.breadcrumbs && item.breadcrumbs.length > 0 && ( - - {item.breadcrumbs.map((crumb, idx) => ( - - {highlightMatch(crumb)} - {idx < item.breadcrumbs.length - 1 && " > "} - - ))} - {" > "} - {highlightMatch(item.title)} - - )} - - Path: {highlightMatch(item.path)} - - - - - - ); - })} - - ) : ( - No results found. - ) - ) : ( - - Type something to search by title or path. - - )} - - - - - - - ); -}; diff --git a/src/layouts/top-nav.js b/src/layouts/top-nav.js index 917fffbbe275..4fe373bb2623 100644 --- a/src/layouts/top-nav.js +++ b/src/layouts/top-nav.js @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import NextLink from "next/link"; import PropTypes from "prop-types"; import Bars3Icon from "@heroicons/react/24/outline/Bars3Icon"; +import MagnifyingGlassIcon from "@heroicons/react/24/outline/MagnifyingGlassIcon"; import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; import SunIcon from "@heroicons/react/24/outline/SunIcon"; import BookmarkIcon from "@mui/icons-material/Bookmark"; @@ -30,7 +31,6 @@ import { ListItem, ListItemText, Typography, - TravelExplore, } from "@mui/material"; import { Logo } from "../components/logo"; import { useSettings } from "../hooks/use-settings"; @@ -40,14 +40,11 @@ import { AccountPopover } from "./account-popover"; import { CippTenantSelector } from "../components/CippComponents/CippTenantSelector"; import { NotificationsPopover } from "./notifications-popover"; import { useDialog } from "../hooks/use-dialog"; -import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; -import { CippCentralSearch } from "../components/CippComponents/CippCentralSearch"; import { CippUniversalSearchV2 } from "../components/CippCards/CippUniversalSearchV2"; const TOP_NAV_HEIGHT = 64; export const TopNav = (props) => { - const searchDialog = useDialog(); const universalSearchDialog = useDialog(); const { onNavOpen } = props; const settings = useSettings(); @@ -72,6 +69,7 @@ export const TopNav = (props) => { const [flashSort, setFlashSort] = useState(false); const [flashLock, setFlashLock] = useState(false); const [universalSearchKey, setUniversalSearchKey] = useState(0); + const [universalSearchDefaultType, setUniversalSearchDefaultType] = useState("Users"); const itemRefs = useRef({}); const touchDragRef = useRef({ startIdx: null, overIdx: null }); const tenantSelectorRef = useRef(null); @@ -202,11 +200,8 @@ export const TopNav = (props) => { const popoverOpen = Boolean(anchorEl); const popoverId = popoverOpen ? "bookmark-popover" : undefined; - const openSearch = useCallback(() => { - searchDialog.handleOpen(); - }, [searchDialog.handleOpen]); - - const openUniversalSearch = useCallback(() => { + const openUniversalSearch = useCallback((defaultType = "Users") => { + setUniversalSearchDefaultType(defaultType); universalSearchDialog.handleOpen(); }, [universalSearchDialog.handleOpen]); @@ -222,17 +217,17 @@ export const TopNav = (props) => { tenantSelectorRef.current?.focus(); } else if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === "K") { event.preventDefault(); - openUniversalSearch(); + openUniversalSearch("Users"); } else if ((event.metaKey || event.ctrlKey) && event.key === "k") { event.preventDefault(); - openSearch(); + openUniversalSearch("Pages"); } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [openSearch, openUniversalSearch]); + }, [openUniversalSearch]); return ( { {!mdDown && ( openUniversalSearch("Users")} title="Open Universal Search (Ctrl/Cmd+Shift+K)" > @@ -307,11 +302,17 @@ export const TopNav = (props) => { )} - openSearch()}> - - - - + {!mdDown && ( + openUniversalSearch("Pages")} + title="Open Page Search (Ctrl/Cmd+K)" + > + + + + + )} {showPopoverBookmarks && ( <> @@ -621,12 +622,12 @@ export const TopNav = (props) => { key={universalSearchKey} maxResults={12} autoFocus={true} + defaultSearchType={universalSearchDefaultType} onConfirm={closeUniversalSearch} /> - Date: Wed, 25 Mar 2026 11:38:26 -0400 Subject: [PATCH 35/35] feat: Add "Deny - Remediate" option to deviation action menu for denied standards --- src/pages/tenant/manage/drift.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pages/tenant/manage/drift.js b/src/pages/tenant/manage/drift.js index e2ad9865383b..612200a44560 100644 --- a/src/pages/tenant/manage/drift.js +++ b/src/pages/tenant/manage/drift.js @@ -2126,6 +2126,15 @@ const ManageDriftPage = () => { open={Boolean(anchorEl[`denied-${item.id}`])} onClose={() => handleMenuClose(`denied-${item.id}`)} > + { + handleDeviationAction("deny-remediate", item); + handleMenuClose(`denied-${item.id}`); + }} + > + + Deny - Remediate to align with template + { handleDeviationAction("accept", item);