From ed467991bb2ba06997b0081e55da9997022fe109 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Sep 2026 13:28:38 +0000 Subject: [PATCH] Merge pull request #506 from CyberDrain/dev Dev to release Synced from CyberDrain/CIPP@0e60de03aca689d7deff125dd9ba56246a4188de --- .storybook/main.mjs | 31 +- .storybook/vitest.setup.js | 5 +- eslint.config.mjs | 3 + package.json | 51 +- public/mockServiceWorker.js | 30 +- public/version.json | 4 +- src/api/ApiCall.jsx | 22 +- ...tton.js => BECRemediationReportButton.jsx} | 28 +- src/components/CSVReader.jsx | 6 +- .../CippAllTenants/AllTenantsDashboard.jsx | 125 +- .../CippAllTenants/AllTenantsPrimitives.jsx | 137 +- .../CippAllTenants/AllTenantsSecureScore.jsx | 69 +- .../CippAllTenants/AllTenantsTestResults.jsx | 5 +- .../CippBaselineStandardDialog.jsx | 122 +- .../CippBaselineStandardItem.jsx | 73 +- .../CippBaselineStandardSettings.jsx | 80 +- .../CippBaselineWhatIfReport.jsx | 12 +- .../CippCards/CippBannerListCard.jsx | 81 +- src/components/CippCards/CippButtonCard.jsx | 12 +- src/components/CippCards/CippChartCard.jsx | 75 +- src/components/CippCards/CippDomainCards.jsx | 37 +- .../CippCards/CippExchangeInfoCard.jsx | 56 +- src/components/CippCards/CippImageCard.jsx | 35 +- src/components/CippCards/CippInfoBar.jsx | 32 +- src/components/CippCards/CippInfoCard.jsx | 18 +- src/components/CippCards/CippListitemCard.jsx | 28 +- src/components/CippCards/CippPageCard.jsx | 2 +- .../CippCards/CippRemediationCard.jsx | 13 +- .../CippCards/CippSharePointQuotaCard.jsx | 30 +- .../CippCards/CippStandardsDialog.jsx | 162 +- .../CippCards/CippUniversalSearch.jsx | 33 +- .../CippCards/CippUniversalSearchV2.jsx | 238 +- src/components/CippCards/CippUserInfoCard.jsx | 116 +- .../CippComponents/AlertsOverviewCard.jsx | 68 +- .../AppApprovalTemplateForm.jsx | 2 +- .../CippComponents/AppRegistrationActions.jsx | 29 +- .../CippComponents/AssessmentCard.jsx | 99 +- .../CippComponents/AuthMethodCard.jsx | 14 +- .../CippComponents/AuthMethodSankey.jsx | 2 +- .../CippComponents/BPASyncDialog.jsx | 4 +- .../CippComponents/BreachSearchDialog.jsx | 4 +- .../CippComponents/CIPPM365OAuthButton.jsx | 41 +- .../CippComponents/CippAddConnectorDrawer.jsx | 6 +- .../CippComponents/CippAddContactDrawer.jsx | 6 +- .../CippComponents/CippAddDomainDrawer.jsx | 6 +- .../CippComponents/CippAddEquipmentDrawer.jsx | 6 +- .../CippComponents/CippAddRoomDrawer.jsx | 6 +- .../CippComponents/CippAddRoomListDrawer.jsx | 10 +- .../CippAddTenantAllowBlockListDrawer.jsx | 6 +- .../CippAddTenantGroupDrawer.jsx | 6 +- .../CippAddTestReportDrawer.jsx | 31 +- .../CippAddTransportRuleDrawer.jsx | 6 +- .../CippComponents/CippAddUserDrawer.jsx | 15 +- .../CippComponents/CippAlertSnoozeDialog.jsx | 21 +- .../CippComponents/CippAliasDialog.jsx | 15 +- .../CippComponents/CippApiDialog.jsx | 68 +- .../CippComponents/CippApiLogsDrawer.jsx | 15 +- .../CippComponents/CippApiResults.jsx | 156 +- .../CippAppApprovalTemplateDrawer.jsx | 6 +- .../CippAppPermissionBuilder.jsx | 93 +- .../CippAppRegistrationPermissions.jsx | 37 +- .../CippComponents/CippAppTemplateDrawer.jsx | 67 +- .../CippApplicationDeployDrawer.jsx | 133 +- .../CippAuditLogSearchDrawer.jsx | 4 +- .../CippComponents/CippAuthShell.jsx | 19 +- .../CippComponents/CippAutocomplete.jsx | 270 +- .../CippAutopilotProfileDrawer.jsx | 6 +- .../CippAutopilotStatusPageDrawer.jsx | 6 +- .../CippBackupScheduleDrawer.jsx | 6 +- .../CippComponents/CippBitlockerKeySearch.jsx | 58 +- .../CippComponents/CippBookmarkStar.jsx | 7 +- .../CippComponents/CippBottomSheet.jsx | 33 +- .../CippComponents/CippBreadcrumbNav.jsx | 34 +- .../CippBulkInviteGuestDrawer.jsx | 8 +- .../CippComponents/CippBulkUserDrawer.jsx | 8 +- .../CippComponents/CippCADeployDrawer.jsx | 15 +- .../CippComponents/CippCAPolicyBuilder.jsx | 244 +- .../CippCheckUserAccessDialog.jsx | 18 +- .../CippComponents/CippCopyToClipboard.jsx | 6 +- .../CippComponents/CippCoverageHeatmap.jsx | 20 +- .../CippCredentialExpandList.jsx | 18 +- .../CippComponents/CippCsvExportButton.jsx | 4 +- .../CippComponents/CippCustomVariables.jsx | 36 +- .../CippComponents/CippDateRangeFilter.jsx | 6 +- .../CippDeployCompliancePolicyDrawer.jsx | 10 +- .../CippDeployContactTemplateDrawer.jsx | 6 +- .../CippComponents/CippDevOptions.jsx | 18 +- .../CippComponents/CippDocsLookup.jsx | 4 +- .../CippDomainServiceConfigurationRecords.jsx | 8 +- .../CippDomainVerificationRecords.jsx | 8 +- .../CippDriftManagementActions.jsx} | 13 +- .../CippEnterpriseAppPermissions.jsx | 37 +- .../CippComponents/CippEntitySwitcher.jsx | 37 +- .../CippComponents/CippErrorState.jsx | 22 +- .../CippComponents/CippExchangeActions.jsx | 68 +- .../CippForefrontHeaderDialog.jsx | 6 +- .../CippComponents/CippFormComponent.jsx | 217 +- .../CippComponents/CippFormCondition.jsx | 4 +- .../CippComponents/CippFormInputArray.jsx | 8 +- .../CippComponents/CippGdapActions.jsx | 32 +- .../CippComponents/CippHVEUserDrawer.jsx | 6 +- .../CippImpersonationBanner.jsx | 17 +- .../CippComponents/CippInfoTooltip.jsx | 4 +- .../CippIntuneDeviceActions.jsx | 224 +- .../CippIntunePolicyActions.jsx | 26 +- .../CippIntunePolicyDetails.jsx | 14 +- .../CippIntuneSettingsEditor.jsx | 104 +- .../CippComponents/CippInviteGuestDrawer.jsx | 6 +- .../CippJitRoleTemplateApply.jsx | 70 + .../CippComponents/CippJobProgress.jsx | 242 ++ .../CippLibraryPermissionsDialog.jsx | 29 +- .../CippLicenseDetailsDrawer.jsx | 15 +- .../CippComponents/CippLocationDialog.jsx | 4 +- .../CippMailboxRestoreDrawer.jsx | 40 +- .../CippComponents/CippMaintenanceBanner.jsx | 45 +- .../CippMessageDeliveryInfo.jsx | 7 +- .../CippComponents/CippMessageViewer.jsx | 122 +- .../CippComponents/CippMfaVerifyForm.jsx | 161 ++ .../CippComponents/CippMobileTenantPicker.jsx | 59 +- .../CippComponents/CippMultiQueueTracker.jsx | 39 +- .../CippComponents/CippOffCanvas.jsx | 34 +- .../CippOffboardingDefaultSettings.jsx | 22 +- .../CippComponents/CippPageActionsFab.jsx | 11 +- .../CippComponents/CippPermissionPreview.jsx | 184 +- .../CippPermissionSetDrawer.jsx | 6 +- .../CippPolicyCompareDialog.jsx | 33 +- .../CippComponents/CippPolicyDeployDrawer.jsx | 22 +- .../CippComponents/CippPolicyDiffTable.jsx | 6 +- .../CippComponents/CippPolicyImportDrawer.jsx | 53 +- .../CippComponents/CippQuarantineDetails.jsx | 42 +- .../CippComponents/CippQuarantineTable.jsx | 57 +- .../CippComponents/CippQueryRefreshButton.jsx | 4 +- .../CippComponents/CippReportDBControls.jsx | 28 +- .../CippComponents/CippReportToolbar.jsx | 51 +- .../CippRestoreBackupDrawer.jsx | 6 +- .../CippComponents/CippRestoreWizard.jsx | 66 +- .../CippReusableSettingsDeployDrawer.jsx | 4 +- .../CippRoleAssignmentActions.jsx | 224 ++ .../CippComponents/CippRoleOverview.jsx | 36 +- src/components/CippComponents/CippSankey.jsx | 20 +- .../CippScheduledTaskActions.jsx | 16 +- .../CippComponents/CippSchedulerCountdown.jsx | 245 ++ .../CippComponents/CippSchedulerDrawer.jsx | 6 +- .../CippComponents/CippScriptDiff.jsx | 8 +- .../CippComponents/CippSettingsSideBar.jsx | 1 + .../CippSharePointBrowserBanner.jsx | 456 +++- .../CippSharePointBrowserPermissions.jsx | 218 +- .../CippSharePointBrowserProperties.jsx | 358 ++- .../CippSharePointBrowserRowActions.jsx | 137 + .../CippSharePointBrowserStorage.jsx | 349 +-- .../CippSharePointFolderView.jsx | 1372 ++++++---- .../CippSharePointLibraryCopyDialog.jsx | 802 ++++++ .../CippSharePointPermissionEditor.jsx | 17 +- ...ppSharePointRecycleFolderRestoreDialog.jsx | 114 + .../CippSharePointTemplateBuilder.jsx | 94 +- .../CippSharePointTemplateDeployDrawer.jsx | 6 +- .../CippSharePointVersionCleanupFields.jsx | 365 +++ .../CippSharedMailboxDrawer.jsx | 6 +- .../CippComponents/CippSitRulePackDetails.jsx | 14 +- .../CippComponents/CippSitTemplateDetails.jsx | 6 +- .../CippSiteRecycleBinDialog.jsx | 4 +- .../CippComponents/CippSpeedDial.jsx | 7 +- src/components/CippComponents/CippSponsor.jsx | 7 +- .../CippStorageCleanupDrawer.jsx | 435 ++++ .../CippSupportBundleDialog.jsx | 38 +- .../CippTabNavigationSection.jsx | 5 +- .../CippComponents/CippTabPicker.jsx | 5 +- .../CippComponents/CippTemplateCatalog.jsx | 76 +- .../CippComponents/CippTemplateEditor.jsx | 13 +- .../CippTemplateFieldRenderer.jsx | 40 +- .../CippTemplatePackageManager.jsx | 336 +++ ...CippTenantAllowBlockListTemplateDrawer.jsx | 6 +- .../CippTenantGroupOffCanvas.jsx | 34 +- .../CippTenantGroupRuleBuilder.jsx | 10 +- .../CippComponents/CippTenantLookup.jsx | 74 +- .../CippComponents/CippTenantSelector.jsx | 15 +- .../CippComponents/CippTranslations.jsx | 8 + .../CippTransportRuleDrawer.jsx | 6 +- .../CippComponents/CippTutorialDialog.jsx | 43 +- .../CippComponents/CippUserActions.jsx | 172 +- .../CippComponents/CippUserPhotoManager.jsx | 51 +- .../CippUserReportedMessagesTable.jsx | 412 +++ .../CippVariableAutocomplete.jsx | 18 +- ...bleChipList.js => CollapsibleChipList.jsx} | 0 .../CippComponents/DomainAnalyserDialog.jsx | 4 +- .../CippComponents/EnrollmentProfileTabs.jsx | 52 +- .../CippComponents/EnterpriseAppActions.jsx | 78 +- .../CippComponents/FailedPaymentDialog.jsx | 1 - .../ForcedSsoMigrationDialog.jsx | 43 +- .../CippComponents/LegacyRedirectDialog.jsx | 6 +- src/components/CippComponents/LicenseCard.jsx | 36 +- src/components/CippComponents/MFACard.jsx | 12 +- .../CippComponents/MailboxRestoreDetails.jsx | 7 +- .../CippComponents/ScheduledTaskDetails.jsx | 159 +- .../CippComponents/SecureScoreCard.jsx | 96 +- .../CippComponents/SecureScoreChart.jsx | 153 -- .../CippComponents/SetupGatePage.jsx | 11 +- .../SubscriptionEndedDialog.jsx | 1 - .../CippComponents/TenantInfoCard.jsx | 28 +- .../CippComponents/TenantMetricsGrid.jsx | 36 +- .../CippAddEditGdapRoleTemplate.jsx | 206 +- .../CippFormPages/CippAddEditUser.jsx | 297 ++- .../CippFormPages/CippAddGroupForm.jsx | 4 +- .../CippFormPages/CippAddRoomListForm.jsx | 7 +- .../CippExchangeSettingsForm.jsx | 19 +- src/components/CippFormPages/CippFormPage.jsx | 14 +- src/components/CippFormPages/CippJSONView.jsx | 33 +- .../CippPIMRoleSettingsTemplateForm.jsx | 392 +++ .../CippSafeLinksPolicyRuleForm.jsx | 18 +- .../CippFormPages/CippSchedulerForm.jsx | 62 +- .../CippApiClientManagement.jsx | 122 +- .../CippIntegrations/CippApiDocumentation.jsx | 13 +- .../CippIntegrationFieldMapping.jsx | 8 +- .../CippIntegrationTenantMapping.jsx | 133 +- .../CippPdf/CippBrandingReportPreview.jsx | 3 + src/components/CippPdf/CippPdfPreview.jsx | 55 +- .../CippPdf/MailFlowReportButton.jsx | 454 ++++ .../CippPdf/PermissionsReportButton.jsx | 21 +- .../CippPdf/SharingReportButton.jsx | 21 +- src/components/CippPdf/index.js | 1 + src/components/CippPdf/measureText.js | 2 +- src/components/CippPdf/previewSampleData.js | 52 + src/components/CippPdf/reportTheme.js | 2 +- src/components/CippPdf/useBrandingSettings.js | 43 +- .../CippSettings/CippAppServiceDomains.jsx | 219 +- .../CippSettings/CippBackendCard.jsx | 11 +- .../CippBackupRetentionSettings.jsx | 4 +- .../CippSettings/CippBackupSettings.jsx | 4 +- .../CippSettings/CippBrandingCoverPreview.jsx | 18 + .../CippSettings/CippBrandingSettings.jsx | 132 +- .../CippSettings/CippCacheSettings.jsx | 4 +- .../CippSettings/CippContainerManagement.jsx | 55 +- .../CippSettings/CippDnsSettings.jsx | 4 +- .../CippSettings/CippGDAP/CippFlowDiagram.jsx | 22 +- .../CippSettings/CippGDAP/CippGDAPTrace.jsx | 4 +- .../CippGDAP/CippGDAPTraceResults.jsx | 145 +- .../CippGDAP/CippPathVisualization.jsx | 61 +- .../CippSettings/CippGDAPResults.jsx | 15 +- .../CippSettings/CippJitAdminSettings.jsx | 4 +- .../CippSettings/CippLogRetentionSettings.jsx | 4 +- .../CippSettings/CippPasswordSettings.jsx | 4 +- .../CippSettings/CippPermissionCheck.jsx | 29 +- .../CippSettings/CippPermissionReport.jsx | 22 +- .../CippSettings/CippPermissionResults.jsx | 17 +- .../CippSettings/CippRoleAddEdit.jsx | 140 +- src/components/CippSettings/CippRoles.jsx | 17 +- .../CippSettings/CippSSOSettings.jsx | 82 +- .../CippSettings/CippSiemSettings.jsx | 4 +- .../CippSettings/CippTenantResults.jsx | 7 +- .../CippSettings/CippUserManagement.jsx | 64 +- .../CippSettings/CippVersionProperties.jsx | 8 +- .../CippStandards/CippStandardAccordion.jsx | 114 +- .../CippStandards/CippStandardDialog.jsx | 85 +- .../CippStandards/CippStandardsSideBar.jsx | 75 +- ...eToptoolbar.js => CIPPTableToptoolbar.jsx} | 209 +- src/components/CippTable/CippCellText.jsx | 82 + .../{CippDataTable.js => CippDataTable.jsx} | 822 +++++- .../CippTable/CippDataTableButton.jsx | 6 +- ...icsFilter.js => CippDiagnosticsFilter.jsx} | 18 +- ...rFilter.js => CippGraphExplorerFilter.jsx} | 29 +- ...r.js => CippGraphExplorerSimpleFilter.jsx} | 17 +- .../CippTable/CippMobileCardList.jsx | 93 +- .../CippTable/CippMobileTableControls.jsx | 116 +- ...ppQueueTracker.js => CippQueueTracker.jsx} | 49 +- .../CippTable/CippTableCardButton.jsx | 6 +- .../CippTable/CippTableFilterSheet.jsx | 46 +- .../CippTable/util-columnsFromAPI.js | 9 +- src/components/CippTable/util-row-open.js | 157 ++ .../CippTable/util-row-text-interaction.js | 14 + src/components/CippTable/util-subTables.js | 55 + src/components/CippTable/util-tablemode.js | 12 +- .../CippTestDetailOffCanvas.jsx | 63 +- .../CippWizard/CIPPDeploymentUpdateTokens.jsx | 58 +- .../CippWizard/CippAddTenantTypeSelection.jsx | 24 +- .../CippWizard/CippCertificateAuthStep.jsx | 106 + .../CippWizard/CippDirectTenantDeploy.jsx | 4 +- .../CippWizard/CippGDAPTenantOnboarding.jsx | 9 +- .../CippWizard/CippGDAPTenantSetup.jsx | 9 +- .../CippWizard/CippIndirectResellerLink.jsx | 18 +- .../CippWizard/CippIntunePolicy.jsx | 10 +- .../CippWizard/CippPSACredentialsStep.jsx | 16 +- src/components/CippWizard/CippSAMDeploy.jsx | 46 +- .../CippWizard/CippTenantModeDeploy.jsx | 74 +- src/components/CippWizard/CippTenantTable.jsx | 16 +- .../CippWizard/CippWizardActionsRow.jsx | 12 +- .../CippWizard/CippWizardAutopilotImport.jsx | 23 +- .../CippWizardAutopilotTypeSelection.jsx | 25 +- .../CippWizard/CippWizardBulkOptions.jsx | 48 - .../CippWizard/CippWizardCSVImport.jsx | 4 +- .../CippWizard/CippWizardConfirmation.jsx | 34 +- .../CippWizard/CippWizardDevicePrepImport.jsx | 23 +- .../CippWizard/CippWizardOffboarding.jsx | 96 +- .../CippWizard/CippWizardOptionsList.jsx | 12 +- src/components/CippWizard/CippWizardPage.jsx | 18 +- .../CippWizard/CippWizardProgressHeader.jsx | 4 +- .../CippWizard/CippWizardStepButtons.jsx | 5 +- .../CippWizardVacationConfirmation.jsx | 76 +- .../CippWizard/OnboardingWizardPage.jsx | 65 +- .../{wizard-steps.js => wizard-steps.jsx} | 12 +- ...ortButton.js => ExecutiveReportButton.jsx} | 83 +- .../{PrivateRoute.js => PrivateRoute.jsx} | 6 +- ...eNotesDialog.js => ReleaseNotesDialog.jsx} | 94 +- .../ReportBuilder/ReportBuilderBlocks.jsx | 68 +- ...portBuilderPDF.js => ReportBuilderPDF.jsx} | 0 ...portButton.js => ShadowAIReportButton.jsx} | 39 +- ...tion-list-item.js => action-list-item.jsx} | 0 .../{action-list.js => action-list.jsx} | 0 .../{actions-menu.js => actions-menu.jsx} | 14 +- ...-actions-menu.js => bulk-actions-menu.jsx} | 17 +- ...tion-dialog.js => confirmation-dialog.jsx} | 13 +- ...csvExportButton.js => csvExportButton.jsx} | 4 +- .../{file-dropzone.js => file-dropzone.jsx} | 14 +- ...-dialog-item.js => filter-dialog-item.jsx} | 15 +- .../{filter-dialog.js => filter-dialog.jsx} | 16 +- .../{images-dialog.js => images-dialog.jsx} | 32 +- src/components/{logo.js => logo.jsx} | 0 .../{pagination.js => pagination.jsx} | 20 +- ...pdfExportButton.js => pdfExportButton.jsx} | 60 +- ...ty-list-item.js => property-list-item.jsx} | 4 +- .../{property-list.js => property-list.jsx} | 0 .../{query-field.js => query-field.jsx} | 6 +- src/components/quill-editor.js | 125 - .../{resource-error.js => resource-error.jsx} | 14 +- ...source-loading.js => resource-loading.jsx} | 7 +- ...navailable.js => resource-unavailable.jsx} | 11 +- src/components/{rtl.js => rtl.jsx} | 0 src/components/{toaster.js => toaster.jsx} | 4 +- ...dget-previewer.js => widget-previewer.jsx} | 4 +- ...s-context.js => release-notes-context.jsx} | 0 ...ttings-context.js => settings-context.jsx} | 0 ...torial-context.js => tutorial-context.jsx} | 0 src/data/AuditLogSchema.json | 36 + src/data/AuditLogTemplates.json | 87 + src/data/CIPPDBCacheTypes.json | 17 + src/data/Extensions.json | 3 + src/data/alerts.json | 96 +- src/data/standards.json | 409 ++- src/hooks/use-actions-dispatch.jsx | 2 +- src/hooks/use-jit-allowed-roles.js | 30 + src/hooks/use-license-currency.js | 28 + src/hooks/use-permissions.js | 199 +- src/hooks/use-sharepoint-browser-recents.js | 49 + src/icons/iconly/bulk/{azure.js => azure.jsx} | 0 src/icons/iconly/bulk/{bag.js => bag.jsx} | 0 src/icons/iconly/bulk/{buy.js => buy.jsx} | 0 .../iconly/bulk/{calendar.js => calendar.jsx} | 0 .../iconly/bulk/{category.js => category.jsx} | 0 src/icons/iconly/bulk/{chart.js => chart.jsx} | 0 .../iconly/bulk/{defender.js => defender.jsx} | 0 .../iconly/bulk/{document.js => document.jsx} | 0 .../iconly/bulk/{exchange.js => exchange.jsx} | 0 src/icons/iconly/bulk/{graph.js => graph.jsx} | 0 .../iconly/bulk/{intune.js => intune.jsx} | 0 .../bulk/{microsoft.js => microsoft.jsx} | 0 .../iconly/bulk/{settings.js => settings.jsx} | 0 .../bulk/{sharepoint.js => sharepoint.jsx} | 0 src/icons/iconly/bulk/{show.js => show.jsx} | 0 src/icons/iconly/bulk/{teams.js => teams.jsx} | 0 .../bulk/{three-user.js => three-user.jsx} | 0 .../iconly/bulk/{ticket.js => ticket.jsx} | 0 .../iconly/bulk/{two-user.js => two-user.jsx} | 0 src/{index.js => index.jsx} | 2 +- src/layouts/HeaderedTabbedLayout.jsx | 48 +- src/layouts/TabbedLayout.jsx | 9 +- ...account-popover.js => account-popover.jsx} | 61 +- src/layouts/{config.js => config.jsx} | 111 +- src/layouts/constants.js | 12 + src/layouts/{footer.js => footer.jsx} | 0 src/layouts/{index.js => index.jsx} | 27 +- ...mobile-nav-item.js => mobile-nav-item.jsx} | 11 +- src/layouts/{mobile-nav.js => mobile-nav.jsx} | 57 +- ...s-popover.js => notifications-popover.jsx} | 70 +- ...on-popover.js => organization-popover.jsx} | 0 ...av-bookmarks.js => side-nav-bookmarks.jsx} | 47 +- .../{side-nav-item.js => side-nav-item.jsx} | 19 +- src/layouts/{side-nav.js => side-nav.jsx} | 37 +- src/layouts/{top-nav.js => top-nav.jsx} | 142 +- src/pages/{401.js => 401.jsx} | 2 +- src/pages/404.js | 21 - src/pages/404.jsx | 83 + src/pages/{500.js => 500.jsx} | 9 +- src/pages/{_app.js => _app.jsx} | 34 +- src/pages/{_document.js => _document.jsx} | 1 + src/pages/{api-offline.js => api-offline.jsx} | 4 +- .../{authredirect.js => authredirect.jsx} | 0 .../cipp-roles/{add.js => add.jsx} | 2 +- .../cipp-roles/{edit.js => edit.jsx} | 23 +- .../cipp-roles/{index.js => index.jsx} | 2 +- .../{cipp-users.js => cipp-users.jsx} | 2 +- ...permissions.js => sam-app-permissions.jsx} | 8 +- .../{sam-app-roles.js => sam-app-roles.jsx} | 6 +- .../authentication/{sso.js => sso.jsx} | 2 +- src/pages/cipp/advanced/cippdb-cache.jsx | 503 ++++ .../{custom-domains.js => custom-domains.jsx} | 2 +- .../{logs.js => logs.jsx} | 33 +- .../{status.js => status.jsx} | 2 +- .../{worker-health.js => worker-health.jsx} | 337 ++- .../{diagnostics.js => diagnostics.jsx} | 88 +- ...change-cmdlets.js => exchange-cmdlets.jsx} | 8 +- .../advanced/super-admin/cipp-roles/add.js | 18 - .../advanced/super-admin/cipp-roles/edit.js | 18 - .../advanced/super-admin/cipp-roles/index.js | 18 - ...-offloading.js => function-offloading.jsx} | 10 +- ...min-settings.js => jit-admin-settings.jsx} | 2 +- .../{tenant-mode.js => tenant-mode.jsx} | 2 +- .../{time-settings.js => time-settings.jsx} | 2 +- ...e-maintenance.js => table-maintenance.jsx} | 52 +- .../cipp/advanced/{timers.js => timers.jsx} | 8 +- .../directory-extensions/{add.js => add.jsx} | 2 +- .../{index.js => index.jsx} | 9 +- .../custom-data/mappings/{add.js => add.jsx} | 2 +- .../mappings/{edit.js => edit.jsx} | 2 +- .../mappings/{index.js => index.jsx} | 12 +- .../schema-extensions/{add.js => add.jsx} | 24 +- .../schema-extensions/{index.js => index.jsx} | 15 +- .../extension-sync/{index.js => index.jsx} | 6 +- .../{configure.js => configure.jsx} | 16 +- .../cipp/integrations/{index.js => index.jsx} | 36 +- .../cipp/integrations/{sync.js => sync.jsx} | 2 +- src/pages/cipp/logs/{index.js => index.jsx} | 50 +- .../cipp/logs/{logentry.js => logentry.jsx} | 2 +- .../cipp/{preferences.js => preferences.jsx} | 2 +- .../cipp/scheduler/{index.js => index.jsx} | 29 +- src/pages/cipp/scheduler/job.jsx | 2 +- .../cipp/scheduler/{task.js => task.jsx} | 2 +- src/pages/cipp/settings/backend.js | 127 - src/pages/cipp/settings/backend.jsx | 158 ++ .../cipp/settings/{backup.js => backup.jsx} | 38 +- .../settings/{branding.js => branding.jsx} | 2 +- .../settings/{features.js => features.jsx} | 8 +- .../cipp/settings/{index.js => index.jsx} | 2 +- .../settings/{licenses.js => licenses.jsx} | 17 +- .../{notifications.js => notifications.jsx} | 2 +- ...rtner-webhooks.js => partner-webhooks.jsx} | 32 +- .../password-config/{index.js => index.jsx} | 118 +- .../{permissions.js => permissions.jsx} | 2 +- src/pages/cipp/settings/{siem.js => siem.jsx} | 29 +- .../cipp/settings/{tenants.js => tenants.jsx} | 29 +- .../{snooze-alert.js => snooze-alert.jsx} | 19 +- .../cipp/statistics/{index.js => index.jsx} | 2 +- .../agent365/packages/{index.js => index.jsx} | 2 +- .../copilot-adoption/{index.js => index.jsx} | 2 +- .../copilot-trend/{index.js => index.jsx} | 2 +- .../copilot-usage/{index.js => index.jsx} | 2 +- .../copilot/settings/{index.js => index.jsx} | 10 +- .../copilot/shadow-ai/{index.js => index.jsx} | 101 +- src/pages/{dashboardv1.js => dashboardv1.jsx} | 8 +- .../custom/{index.js => index.jsx} | 3 +- .../devices/{index.js => index.jsx} | 3 +- .../identity/{index.js => index.jsx} | 3 +- src/pages/dashboardv2/{index.js => index.jsx} | 38 +- .../administration/contacts-template/add.jsx | 2 +- .../administration/contacts-template/edit.jsx | 15 +- .../contacts-template/index.jsx | 15 +- .../email/administration/contacts/edit.jsx | 16 +- .../contacts/{index.js => index.jsx} | 12 +- .../deleted-mailboxes/{index.js => index.jsx} | 2 +- .../policies/{index.js => index.jsx} | 10 +- .../tags/{index.js => index.jsx} | 10 +- .../exchange-retention/tags/tag.jsx | 4 +- .../hve-accounts/{index.js => index.jsx} | 29 +- .../mailbox-rules/{index.js => index.jsx} | 10 +- .../administration/mailboxes/addshared.jsx | 2 +- .../mailboxes/{index.js => index.jsx} | 13 +- .../quarantine/{files.js => files.jsx} | 2 +- .../quarantine/{index.js => index.jsx} | 2 +- .../administration/quarantine/tabOptions.json | 5 + .../quarantine/{teams.js => teams.jsx} | 2 +- .../quarantine/user-reported.jsx | 14 + .../restricted-users/{index.js => index.jsx} | 6 +- .../{index.js => index.jsx} | 8 +- .../{index.js => index.jsx} | 12 +- .../{index.js => index.jsx} | 7 +- .../{index.js => index.jsx} | 18 +- .../{index.js => index.jsx} | 8 +- .../{index.js => index.jsx} | 10 +- .../{index.js => index.jsx} | 8 +- .../reports/mail-flow-statistics/index.jsx | 317 +++ .../mailbox-activity/{index.js => index.jsx} | 21 +- .../{index.js => index.jsx} | 6 +- .../{index.js => index.jsx} | 2 +- .../{index.js => index.jsx} | 14 +- .../{index.js => index.jsx} | 2 +- .../malware-filters/{index.js => index.jsx} | 9 +- .../{index.js => index.jsx} | 10 +- .../resources/management/equipment/edit.jsx | 21 +- .../equipment/{index.js => index.jsx} | 16 +- .../resources/management/list-rooms/edit.jsx | 27 +- .../list-rooms/{index.js => index.jsx} | 16 +- .../resources/management/room-lists/edit.jsx | 12 +- .../room-lists/{index.js => index.jsx} | 10 +- .../{index.js => index.jsx} | 9 +- .../spamfilter/list-connectionfilter/add.jsx | 4 +- .../{index.js => index.jsx} | 8 +- .../list-quarantine-policies/add.jsx | 2 +- .../{index.js => index.jsx} | 37 +- .../email/spamfilter/list-spamfilter/add.jsx | 2 +- .../list-spamfilter/{index.js => index.jsx} | 16 +- .../list-templates/{index.js => index.jsx} | 9 +- .../tools/mail-test/{index.js => index.jsx} | 2 +- .../{index.js => index.jsx} | 2 +- .../email/tools/mailbox-restores/add.jsx | 2 +- .../mailbox-restores/{index.js => index.jsx} | 10 +- .../{index.js => index.jsx} | 12 +- .../message-trace/historical-search/index.jsx | 358 +++ src/pages/email/tools/message-trace/index.js | 351 --- src/pages/email/tools/message-trace/index.jsx | 446 ++++ .../email/tools/message-trace/tabOptions.json | 12 + .../message-viewer/{index.js => index.jsx} | 2 +- .../{index.js => index.jsx} | 16 +- .../list-connectors/{index.js => index.jsx} | 14 +- .../list-rules/{index.js => index.jsx} | 17 +- .../list-templates/{index.js => index.jsx} | 9 +- .../approval-requests/{index.js => index.jsx} | 8 +- .../MEM/assignment-filter-templates/add.jsx | 2 +- .../{deploy.js => deploy.jsx} | 2 +- .../MEM/assignment-filter-templates/edit.jsx | 2 +- .../{index.js => index.jsx} | 15 +- .../endpoint/MEM/assignment-filters/add.jsx | 2 +- .../endpoint/MEM/assignment-filters/edit.jsx | 22 +- .../{index.js => index.jsx} | 18 +- .../bitlocker-search/{index.js => index.jsx} | 10 +- .../compare-policies/{index.js => index.jsx} | 18 +- .../endpoint/MEM/devices/device/index.jsx | 123 +- .../MEM/devices/{index.js => index.jsx} | 19 +- .../{index.js => index.jsx} | 10 +- .../{index.js => index.jsx} | 8 +- .../MEM/list-policies/{index.js => index.jsx} | 10 +- src/pages/endpoint/MEM/list-scripts/index.jsx | 25 +- .../endpoint/MEM/list-templates/edit.jsx | 18 +- .../list-templates/{index.js => index.jsx} | 37 +- .../MEM/reusable-settings-templates/add.jsx | 47 +- .../MEM/reusable-settings-templates/edit.jsx | 47 +- .../{index.js => index.jsx} | 13 +- .../endpoint/MEM/reusable-settings/edit.jsx | 2 +- .../reusable-settings/{index.js => index.jsx} | 15 +- .../applications/list/{index.js => index.jsx} | 37 +- .../queue/{index.js => index.jsx} | 9 +- .../templates/{index.js => index.jsx} | 23 +- .../add-device/{index.js => index.jsx} | 2 +- ...d-enterprise.js => android-enterprise.jsx} | 2 +- .../{apple-ade.js => apple-ade.jsx} | 2 +- .../{index.js => index.jsx} | 2 +- ...ows-autopilot.js => windows-autopilot.jsx} | 2 +- .../list-devices/{index.js => index.jsx} | 16 +- .../list-profiles/{index.js => index.jsx} | 6 +- .../list-status-pages/{index.js => index.jsx} | 2 +- .../{index.js => index.jsx} | 8 +- .../{index.js => index.jsx} | 12 +- .../detected-apps/{index.js => index.jsx} | 2 +- .../workfromanywhere/{index.js => index.jsx} | 8 +- src/pages/fullPageLoading.js | 30 - src/pages/fullPageLoading.jsx | 30 + .../deleted-items/{index.js => index.jsx} | 8 +- .../{index.js => index.jsx} | 2 +- .../devices/{index.js => index.jsx} | 27 +- .../administration/group-templates/add.jsx | 2 +- .../group-templates/{deploy.js => deploy.jsx} | 2 +- .../administration/group-templates/edit.jsx | 2 +- .../group-templates/{index.js => index.jsx} | 15 +- .../identity/administration/groups/add.jsx | 2 +- .../identity/administration/groups/edit.jsx | 28 +- .../administration/groups/group/index.jsx | 177 +- .../groups/{index.js => index.jsx} | 121 +- .../guest-users/{index.js => index.jsx} | 99 +- .../administration/{index.js => index.jsx} | 2 +- .../jit-admin-templates/add.jsx | 36 +- .../jit-admin-templates/edit.jsx | 29 +- .../{index.js => index.jsx} | 13 +- .../identity/administration/jit-admin/add.jsx | 13 +- .../jit-admin/{index.js => index.jsx} | 6 +- .../administration/jit-role-templates/add.jsx | 67 + .../jit-role-templates/edit.jsx | 87 + .../jit-role-templates/index.jsx | 78 + .../{index.js => index.jsx} | 21 +- .../risky-users/{index.js => index.jsx} | 8 +- .../identity/administration/roles/index.js | 94 - .../identity/administration/roles/index.jsx | 182 ++ .../administration/roles/tabOptions.json | 12 + .../administration/roles/templates/add.jsx | 34 + .../administration/roles/templates/edit.jsx | 53 + .../administration/roles/templates/index.jsx | 155 ++ .../identity/administration/users/add.jsx | 69 - .../identity/administration/users/bulk-add.js | 78 - .../users/{index.js => index.jsx} | 14 +- .../administration/users/patch-wizard.jsx | 179 +- .../administration/users/tabOptions.json | 12 + .../administration/users/user-defaults.jsx} | 62 +- .../administration/users/user/bec.jsx | 112 +- .../users/user/conditional-access.jsx | 15 +- .../administration/users/user/edit.jsx | 22 +- .../administration/users/user/exchange.jsx | 111 +- .../administration/users/user/index.jsx | 179 +- .../vacation-mode/add/{index.js => index.jsx} | 2 +- .../vacation-mode/{index.js => index.jsx} | 17 +- .../{index.js => index.jsx} | 2 +- .../identity/reports/group-usage/index.jsx | 87 + .../{index.js => index.jsx} | 36 +- .../index.js => identity/reports/index.jsx} | 2 +- .../mfa-report/{index.js => index.jsx} | 10 +- .../risk-detections/{index.js => index.jsx} | 6 +- .../signin-report/{index.js => index.jsx} | 19 +- src/pages/{license.js => license.jsx} | 2 +- src/pages/{loading.js => loading.jsx} | 0 src/pages/logout/{index.js => index.jsx} | 2 +- .../{onboardingv2.js => onboardingv2.jsx} | 2 +- .../dlp-templates/{index.js => index.jsx} | 11 +- .../compliance/dlp/{index.js => index.jsx} | 15 +- .../labels-templates/{index.js => index.jsx} | 11 +- .../compliance/labels/{index.js => index.jsx} | 13 +- .../{index.js => index.jsx} | 11 +- .../retention/{index.js => index.jsx} | 24 +- .../sit-templates/{index.js => index.jsx} | 11 +- .../compliance/sit/{index.js => index.jsx} | 11 +- .../{index.js => index.jsx} | 22 +- .../deployment/{index.js => index.jsx} | 34 +- .../list-defender-tvm/{index.js => index.jsx} | 2 +- .../list-defender/{index.js => index.jsx} | 2 +- .../list-alerts/{index.js => index.jsx} | 8 +- .../list-check-alerts/{index.js => index.jsx} | 2 +- .../list-incidents/{index.js => index.jsx} | 30 +- .../list-mdo-alerts/{index.js => index.jsx} | 15 +- .../cve-report/{index.js => index.jsx} | 2 +- .../{index.js => index.jsx} | 2 +- .../mde-onboarding/{index.js => index.jsx} | 24 +- .../safelinks/safelinks-template/add.jsx | 4 +- .../safelinks/safelinks-template/create.jsx | 2 +- .../safelinks/safelinks-template/edit.jsx | 37 +- .../safelinks/safelinks-template/index.jsx | 16 +- .../security/safelinks/safelinks/add.jsx | 4 +- .../security/safelinks/safelinks/edit.jsx | 34 +- .../security/safelinks/safelinks/index.jsx | 23 +- .../{deleted-sites.js => deleted-sites.jsx} | 6 +- .../{external-users.js => external-users.jsx} | 6 +- .../onedrive/{index.js => index.jsx} | 36 +- .../{index.js => index.jsx} | 51 +- .../sharepoint-templates/{add.js => add.jsx} | 18 +- .../{index.js => index.jsx} | 15 +- .../sharepoint/{add-site.js => add-site.jsx} | 2 +- .../{bulk-add-site.js => bulk-add-site.jsx} | 2 +- .../sharepoint/{index.js => index.jsx} | 225 +- src/pages/teams-share/sharepoint2/index.js | 322 --- src/pages/teams-share/sharepoint2/index.jsx | 1068 ++++++++ .../sharing-report/{index.js => index.jsx} | 69 +- .../teams-share/storage-report/index.jsx | 1111 ++++++++ .../business-voice/{index.js => index.jsx} | 10 +- src/pages/teams-share/teams/list-team/add.jsx | 2 +- .../teams/list-team/{index.js => index.jsx} | 16 +- .../teams-activity/{index.js => index.jsx} | 2 +- .../administration/add-subscription/index.jsx | 2 +- .../alert-configuration/alert.jsx | 146 +- .../{index.js => index.jsx} | 28 +- .../{snoozed-alerts.js => snoozed-alerts.jsx} | 6 +- .../{index.js => index.jsx} | 23 +- .../applications/app-registration/index.jsx | 112 +- .../app-registration/permissions.jsx | 41 +- ...registrations.js => app-registrations.jsx} | 6 +- .../applications/enterprise-app/index.jsx | 98 +- .../enterprise-app/permissions.jsx | 41 +- ...enterprise-apps.js => enterprise-apps.jsx} | 25 +- .../permission-sets/{add.js => add.jsx} | 10 +- .../permission-sets/{edit.js => edit.jsx} | 2 +- .../permission-sets/{index.js => index.jsx} | 10 +- .../templates/{add.js => add.jsx} | 2 +- .../templates/{edit.js => edit.jsx} | 2 +- .../templates/{index.js => index.jsx} | 17 +- .../audit-logs/{coverage.js => coverage.jsx} | 100 +- ...rectory-audits.js => directory-audits.jsx} | 4 +- .../audit-logs/{index.js => index.jsx} | 20 +- .../audit-logs/{log.js => log.jsx} | 6 +- ...manual-searches.js => manual-searches.jsx} | 10 +- .../{search-results.js => search-results.jsx} | 10 +- .../audit-logs/{searches.js => searches.jsx} | 6 +- .../{index.js => index.jsx} | 15 +- ...-campaign.js => registration-campaign.jsx} | 10 +- .../domains/{index.js => index.jsx} | 10 +- .../administration/{index.js => index.jsx} | 2 +- .../{index.js => index.jsx} | 2 +- .../securescore/{index.js => index.jsx} | 16 +- .../securescore/{table.js => table.jsx} | 13 +- .../tenantlookup/{index.js => index.jsx} | 2 +- .../tenants/{add.js => add.jsx} | 11 +- .../tenants/{edit.js => edit.jsx} | 38 +- ...obal-variables.js => global-variables.jsx} | 2 +- .../tenants/groups/{edit.js => edit.jsx} | 2 +- .../tenants/groups/{index.js => index.jsx} | 16 +- .../tenants/{index.js => index.jsx} | 13 +- src/pages/tenant/backup/backup-wizard/add.jsx | 2 +- .../backup-wizard/{index.js => index.jsx} | 9 +- .../tenant/backup/backup-wizard/restore.jsx | 2 +- .../alignment/{index.js => index.jsx} | 679 +++-- .../tenant/baselines/{index.js => index.jsx} | 43 +- src/pages/tenant/baselines/template.jsx | 112 +- .../templates/{index.js => index.jsx} | 124 +- .../conditional/deploy-vacation/add.jsx | 171 -- .../conditional/deploy-vacation/index.js | 25 - .../conditional/list-named-locations/add.jsx | 2 +- .../{index.js => index.jsx} | 30 +- .../tenant/conditional/list-policies/edit.jsx | 100 +- .../list-policies/{index.js => index.jsx} | 36 +- .../conditional/list-template/create.jsx | 2 +- .../tenant/conditional/list-template/edit.jsx | 6 +- .../list-template/{index.js => index.jsx} | 32 +- .../gdap-management/{index.js => index.jsx} | 70 +- .../invites/{add.js => add.jsx} | 28 +- .../invites/{index.js => index.jsx} | 11 +- .../{offboarding.js => offboarding.jsx} | 17 +- .../onboarding/{index.js => index.jsx} | 12 +- .../onboarding/{start.js => start.jsx} | 10 +- .../relationships/{index.js => index.jsx} | 6 +- .../relationship/{index.js => index.jsx} | 6 +- .../{mappings.js => mappings.jsx} | 6 +- .../role-templates/{add.js => add.jsx} | 24 +- .../gdap-management/role-templates/edit.js | 75 - .../gdap-management/role-templates/edit.jsx | 97 + .../gdap-management/role-templates/index.js | 121 - .../gdap-management/role-templates/index.jsx | 221 ++ .../role-templates/mappings.jsx | 254 ++ src/pages/tenant/gdap-management/roles/add.js | 328 --- .../tenant/gdap-management/roles/add.jsx | 185 ++ .../tenant/gdap-management/roles/index.js | 87 - .../tenant/gdap-management/roles/index.jsx | 18 + .../tenant/gdap-management/tabOptions.json | 5 - ...ied-standards.js => applied-standards.jsx} | 179 +- ...ion-backup.js => configuration-backup.jsx} | 88 +- .../tenant/manage/{drift.js => drift.jsx} | 322 ++- src/pages/tenant/manage/{edit.js => edit.jsx} | 38 +- .../tenant/manage/{history.js => history.jsx} | 91 +- ...cies-deployed.js => policies-deployed.jsx} | 43 +- src/pages/tenant/manage/tabOptions.json | 9 +- .../{index.js => index.jsx} | 2 +- .../{index.js => index.jsx} | 7 +- .../{index.js => index.jsx} | 16 +- .../index.js => tenant/reports/index.jsx} | 2 +- .../reports/list-csp-licenses/index.jsx | 19 +- .../list-licenses/{index.js => index.jsx} | 20 +- .../reports/list-licenses/optimization.jsx | 400 +++ .../tenant/reports/list-licenses/pricing.jsx | 245 ++ .../reports/list-licenses/tabOptions.json | 7 + .../alignment/{index.js => index.jsx} | 81 +- .../bpa-report/{builder.js => builder.jsx} | 19 +- .../bpa-report/{index.js => index.jsx} | 21 +- .../bpa-report/{view.js => view.jsx} | 13 +- .../domains-analyser/{index.js => index.jsx} | 32 +- .../templates/{index.js => index.jsx} | 29 +- .../tenant/standards/templates/template.jsx | 21 +- .../tools/appapproval/{index.js => index.jsx} | 2 +- .../{index.js => index.jsx} | 2 +- .../tools/geoiplookup/{index.js => index.jsx} | 17 +- .../graph-explorer/{index.js => index.jsx} | 6 +- .../{index.js => index.jsx} | 2 +- .../tenantlookup/{index.js => index.jsx} | 2 +- .../breachlookup/{index.js => index.jsx} | 10 +- .../{catalog.js => catalog.jsx} | 13 +- .../community-repos/{index.js => index.jsx} | 83 +- .../community-repos/{repo.js => repo.jsx} | 0 src/pages/tools/custom-tests/add.jsx | 301 ++- src/pages/tools/custom-tests/index.jsx | 31 +- src/pages/tools/custom-tests/versions.jsx | 11 +- .../builder/{index.js => index.jsx} | 222 +- .../generated/{index.js => index.jsx} | 21 +- .../templates/{index.js => index.jsx} | 12 +- .../view/{index.js => index.jsx} | 38 +- src/pages/tools/template-packages/index.jsx | 27 + src/pages/tools/templatelib/index.jsx | 107 +- .../{index.js => index.jsx} | 9 +- ...unauthenticated.js => unauthenticated.jsx} | 61 +- src/sections/dashboard/account/account-2fa.js | 30 - .../dashboard/account/account-details.js | 165 -- .../dashboard/account/account-password.js | 95 - .../dashboard/calendar/calendar-container.js | 46 - .../calendar/calendar-event-dialog.js | 291 --- .../dashboard/calendar/calendar-toolbar.js | 124 - .../card-headings/card-heading-1.js | 7 - .../card-headings/card-heading-2.js | 10 - .../card-headings/card-heading-3.js | 50 - .../components/data-states/data-state-1.js | 25 - .../components/data-states/data-state-2.js | 11 - .../components/data-states/data-state-3.js | 11 - .../dashboard/components/images-uploader.js | 120 - .../dashboard/components/lists/list-1.js | 48 - .../dashboard/components/lists/list-2.js | 63 - .../dashboard/components/lists/list-3.js | 143 - .../dashboard/components/lists/list-4.js | 104 - .../dashboard/components/lists/list-5.js | 113 - .../dashboard/components/onboarding/wizard.js | 134 - .../page-headings/page-heading-1.js | 33 - .../page-headings/page-heading-2.js | 104 - .../page-headings/page-heading-3.js | 59 - .../dashboard/components/stats/stats-1.js | 99 - .../dashboard/components/stats/stats-2.js | 58 - .../dashboard/customers/customer-details.js | 138 - .../dashboard/customers/customer-dialog.js | 200 -- .../customers/customer-latest-orders.js | 260 -- .../dashboard/customers/customer-log.js | 57 - .../dashboard/customers/customer-note-add.js | 97 - .../dashboard/customers/customer-note-card.js | 65 - .../dashboard/customers/customer-notes.js | 45 - .../customers/customer-order-menu.js | 64 - .../customers/customer-orders-table.js | 190 -- .../customers/customer-properties.js | 45 - .../dashboard/customers/customers-search.js | 225 -- .../customers/customers-table-menu.js | 64 - .../dashboard/customers/customers-table.js | 291 --- .../dashboard/foundation/inputs/input-1.js | 32 - .../dashboard/foundation/inputs/input-2.js | 33 - .../dashboard/foundation/inputs/input-3.js | 26 - .../dashboard/foundation/inputs/input-4.js | 31 - .../dashboard/foundation/inputs/input-5.js | 26 - .../dashboard/foundation/tables/table-1.js | 130 - .../dashboard/invoices/invoice-details.js | 60 - .../dashboard/invoices/invoice-line-items.js | 133 - .../invoices/invoice-payment-history.js | 115 - .../dashboard/invoices/invoice-payment.js | 67 - .../invoices/invoice-pdf-document.js | 287 --- .../dashboard/invoices/invoice-pdf-preview.js | 276 -- .../invoices/invoice-quick-actions.js | 196 -- .../dashboard/invoices/invoices-search.js | 239 -- .../dashboard/invoices/invoices-stats.js | 179 -- .../dashboard/invoices/invoices-table-menu.js | 64 - .../dashboard/invoices/invoices-table.js | 300 --- .../dashboard/orders/order-create-dialog.js | 130 - .../dashboard/orders/order-details-dialog.js | 259 -- .../dashboard/orders/order-details.js | 99 - .../dashboard/orders/order-line-items.js | 150 -- .../dashboard/orders/order-payment-dialog.js | 215 -- .../dashboard/orders/order-payment.js | 41 - .../dashboard/orders/order-quick-actions.js | 82 - .../dashboard/orders/orders-dnd-draggable.js | 101 - .../dashboard/orders/orders-dnd-droppable.js | 80 - src/sections/dashboard/orders/orders-dnd.js | 246 -- .../dashboard/orders/orders-search.js | 305 --- .../dashboard/orders/orders-table-menu.js | 64 - src/sections/dashboard/orders/orders-table.js | 335 --- .../organization-billing-details.js | 51 - .../organization/organization-billing-plan.js | 148 -- .../organization-invite-dialog.js | 196 -- .../overview/overview-account-setup.js | 65 - .../dashboard/overview/overview-bills.js | 167 -- .../overview/overview-latest-customers.js | 83 - .../overview/overview-notifications.js | 159 -- .../overview/overview-orders-summary.js | 147 -- .../dashboard/overview/overview-summary.js | 53 - .../products/product-Insights-reviews.js | 119 - .../products/product-Insights-sales.js | 106 - .../products/product-create-dialog.js | 117 - .../products/product-details-dialog.js | 215 -- .../dashboard/products/product-details.js | 81 - .../products/product-insights-channel.js | 138 - .../products/product-insights-rate.js | 133 - .../products/product-quick-actions.js | 144 -- .../products/product-variant-dialog.js | 322 --- .../products/product-variants-table.js | 179 -- .../dashboard/products/product-variants.js | 203 -- .../dashboard/products/products-search.js | 216 -- .../dashboard/products/products-stats.js | 70 - .../dashboard/products/products-table-menu.js | 64 - .../dashboard/products/products-table.js | 301 --- .../dashboard/sales/sales-breakdown.js | 179 -- src/sections/dashboard/sales/sales-kpi.js | 160 -- ...te-components.js => create-components.jsx} | 6 +- ...te-components.js => create-components.jsx} | 30 +- src/theme/dark/create-palette.js | 4 +- src/theme/index.js | 15 +- ...te-components.js => create-components.jsx} | 0 src/utils/cippdb-cache-categories.js | 192 ++ src/utils/gdap-role-options.js | 249 ++ src/utils/get-cipp-column-size.js | 3 + src/utils/get-cipp-error.js | 61 +- src/utils/get-cipp-filter-variant.js | 62 +- ...-formatting.js => get-cipp-formatting.jsx} | 255 +- src/utils/get-cipp-translation.js | 17 +- src/utils/get-cipp-validator.js | 19 +- src/utils/help-links.js | 31 +- src/utils/icon-registry.js | 113 - src/utils/icon-registry.jsx | 837 ++++++ src/utils/markdown-table.js | 8 +- src/utils/{permissions.js => permissions.jsx} | 15 +- src/utils/render-url-value.jsx | 4 +- src/utils/resolve-row-templates.js | 12 + src/utils/route-redirects.js | 43 + src/utils/shadow-ai.js | 27 + src/utils/sharepoint-browser-recents.js | 98 + src/utils/sharepoint-library-copy-eligible.js | 27 + src/utils/sharepoint-recycle-bin-tree.js | 357 +++ src/utils/storage-cleanup-opportunities.js | 277 ++ src/utils/support-bundle.js | 73 +- tests/Overview.mdx | 36 +- tests/api/ApiCall.retry.test.jsx | 146 ++ .../CippComponents/CippAddUserDrawer.test.jsx | 54 +- .../CippComponents/CippApiResults.test.jsx | 31 +- .../CippComponents/CippAutocomplete.test.jsx | 70 + .../CippCopyToClipboard.test.jsx | 14 +- .../CippFormComponentNumber.test.jsx | 61 + .../CippComponents/CippFormCondition.test.jsx | 56 + .../CippComponents/CippFormatting.stories.jsx | 22 +- .../CippIntuneDeviceActions.test.jsx | 84 + .../CippMobileTenantPicker.stories.jsx | 4 +- .../CippPageActionsFab.stories.jsx | 4 +- .../CippPageActionsFab.test.jsx | 2 +- .../CippQuarantineDetails.test.jsx | 57 + .../CippQuarantineTable.test.jsx | 26 +- .../CippSchedulerCountdown.test.jsx | 264 ++ .../CippComponents/CippTabPicker.stories.jsx | 8 +- .../CippUserActions.ooo.test.jsx | 163 ++ .../CippUserReportedMessagesTable.test.jsx | 135 + .../CippComponents/SecureScoreCard.test.jsx | 17 +- .../CippComponents/SetupGatePage.test.jsx | 58 + .../CippPdf/ReportDialogActions.stories.jsx | 4 +- .../CippSettings/CippRoleAddEdit.test.jsx | 54 + .../CippTable/CIPPTableToptoolbar.test.jsx | 93 +- .../CippTable/CippDataTable.stories.jsx | 2 +- .../CippTable/CippDataTable.test.jsx | 104 +- .../CippGraphExplorerSimpleFilter.test.jsx | 14 +- .../CippTable/util-row-open.test.js | 292 +++ .../util-row-text-interaction.test.js | 39 + .../CippTable/util-subTables.test.js | 31 + .../CippTable/util-tablemode.test.jsx | 7 +- .../CippWizardDevicePrepImport.test.jsx | 14 +- .../CippWizard/CippWizardOffboarding.test.jsx | 169 ++ .../components/ExecutiveReportButton.test.jsx | 9 +- .../components/MailFlowReportButton.test.jsx | 37 + tests/components/ReleaseNotesDialog.test.jsx | 2 +- .../ReportBuilder/reportSettings.test.js | 4 +- tests/layouts/TabbedLayout.test.jsx | 14 +- tests/layouts/header-overflow.stories.jsx | 35 +- tests/layouts/notification-badge.stories.jsx | 8 +- tests/lint/mobile-layout-patterns.test.js | 2 +- tests/mocks/cipp-m365-oauth-button.jsx | 10 +- .../{next-dynamic.js => next-dynamic.jsx} | 0 tests/mocks/tiptap-extension-image.js | 6 - tests/pages/GraphExplorerPage.stories.jsx | 2 +- tests/pages/GraphExplorerPage.test.jsx | 2 +- .../pages/GroupViewPage.bulkRefetch.test.jsx | 112 + tests/pages/MessageEncryptionPage.test.jsx | 2 +- .../MessageTracePage.errorSurfacing.test.jsx | 71 + ...eportBuilderPage.scheduleTemplate.test.jsx | 144 ++ .../ShadowAIPage.sortByRiskSeverity.test.jsx | 104 + tests/pages/WorkerHealthPage.test.jsx | 2 +- tests/utils/gdap-role-options.test.js | 399 +++ tests/utils/get-cipp-formatting.test.jsx | 11 + tests/utils/help-links.test.js | 80 + tests/utils/markdown-table.test.js | 17 +- tests/utils/resolve-row-templates.test.js | 6 + tests/viewport.js | 8 +- vitest.config.mjs | 42 +- vitest.setup.js | 17 +- yarn.lock | 2296 ++++++++--------- 948 files changed, 34714 insertions(+), 24395 deletions(-) rename src/components/{BECRemediationReportButton.js => BECRemediationReportButton.jsx} (99%) rename src/{pages/tenant/manage/driftManagementActions.js => components/CippComponents/CippDriftManagementActions.jsx} (89%) create mode 100644 src/components/CippComponents/CippJitRoleTemplateApply.jsx create mode 100644 src/components/CippComponents/CippJobProgress.jsx create mode 100644 src/components/CippComponents/CippMfaVerifyForm.jsx create mode 100644 src/components/CippComponents/CippRoleAssignmentActions.jsx create mode 100644 src/components/CippComponents/CippSchedulerCountdown.jsx create mode 100644 src/components/CippComponents/CippSharePointBrowserRowActions.jsx create mode 100644 src/components/CippComponents/CippSharePointLibraryCopyDialog.jsx create mode 100644 src/components/CippComponents/CippSharePointRecycleFolderRestoreDialog.jsx create mode 100644 src/components/CippComponents/CippSharePointVersionCleanupFields.jsx create mode 100644 src/components/CippComponents/CippStorageCleanupDrawer.jsx create mode 100644 src/components/CippComponents/CippTemplatePackageManager.jsx create mode 100644 src/components/CippComponents/CippUserReportedMessagesTable.jsx rename src/components/CippComponents/{CollapsibleChipList.js => CollapsibleChipList.jsx} (100%) delete mode 100644 src/components/CippComponents/SecureScoreChart.jsx create mode 100644 src/components/CippFormPages/CippPIMRoleSettingsTemplateForm.jsx create mode 100644 src/components/CippPdf/MailFlowReportButton.jsx rename src/components/CippTable/{CIPPTableToptoolbar.js => CIPPTableToptoolbar.jsx} (90%) create mode 100644 src/components/CippTable/CippCellText.jsx rename src/components/CippTable/{CippDataTable.js => CippDataTable.jsx} (71%) rename src/components/CippTable/{CippDiagnosticsFilter.js => CippDiagnosticsFilter.jsx} (95%) rename src/components/CippTable/{CippGraphExplorerFilter.js => CippGraphExplorerFilter.jsx} (97%) rename src/components/CippTable/{CippGraphExplorerSimpleFilter.js => CippGraphExplorerSimpleFilter.jsx} (94%) rename src/components/CippTable/{CippQueueTracker.js => CippQueueTracker.jsx} (93%) create mode 100644 src/components/CippTable/util-row-open.js create mode 100644 src/components/CippTable/util-row-text-interaction.js create mode 100644 src/components/CippWizard/CippCertificateAuthStep.jsx delete mode 100644 src/components/CippWizard/CippWizardBulkOptions.jsx rename src/components/CippWizard/{wizard-steps.js => wizard-steps.jsx} (93%) rename src/components/{ExecutiveReportButton.js => ExecutiveReportButton.jsx} (97%) rename src/components/{PrivateRoute.js => PrivateRoute.jsx} (97%) rename src/components/{ReleaseNotesDialog.js => ReleaseNotesDialog.jsx} (91%) rename src/components/ReportBuilder/{ReportBuilderPDF.js => ReportBuilderPDF.jsx} (100%) rename src/components/{ShadowAIReportButton.js => ShadowAIReportButton.jsx} (97%) rename src/components/{action-list-item.js => action-list-item.jsx} (100%) rename src/components/{action-list.js => action-list.jsx} (100%) rename src/components/{actions-menu.js => actions-menu.jsx} (90%) rename src/components/{bulk-actions-menu.js => bulk-actions-menu.jsx} (90%) rename src/components/{confirmation-dialog.js => confirmation-dialog.jsx} (87%) rename src/components/{csvExportButton.js => csvExportButton.jsx} (97%) rename src/components/{file-dropzone.js => file-dropzone.jsx} (83%) rename src/components/{filter-dialog-item.js => filter-dialog-item.jsx} (93%) rename src/components/{filter-dialog.js => filter-dialog.jsx} (93%) rename src/components/{images-dialog.js => images-dialog.jsx} (91%) rename src/components/{logo.js => logo.jsx} (100%) rename src/components/{pagination.js => pagination.jsx} (86%) rename src/components/{pdfExportButton.js => pdfExportButton.jsx} (73%) rename src/components/{property-list-item.js => property-list-item.jsx} (93%) rename src/components/{property-list.js => property-list.jsx} (100%) rename src/components/{query-field.js => query-field.jsx} (93%) delete mode 100644 src/components/quill-editor.js rename src/components/{resource-error.js => resource-error.jsx} (78%) rename src/components/{resource-loading.js => resource-loading.jsx} (85%) rename src/components/{resource-unavailable.js => resource-unavailable.jsx} (87%) rename src/components/{rtl.js => rtl.jsx} (100%) rename src/components/{toaster.js => toaster.jsx} (92%) rename src/components/{widget-previewer.js => widget-previewer.jsx} (92%) rename src/contexts/{release-notes-context.js => release-notes-context.jsx} (100%) rename src/contexts/{settings-context.js => settings-context.jsx} (100%) rename src/contexts/{tutorial-context.js => tutorial-context.jsx} (100%) create mode 100644 src/hooks/use-jit-allowed-roles.js create mode 100644 src/hooks/use-license-currency.js create mode 100644 src/hooks/use-sharepoint-browser-recents.js rename src/icons/iconly/bulk/{azure.js => azure.jsx} (100%) rename src/icons/iconly/bulk/{bag.js => bag.jsx} (100%) rename src/icons/iconly/bulk/{buy.js => buy.jsx} (100%) rename src/icons/iconly/bulk/{calendar.js => calendar.jsx} (100%) rename src/icons/iconly/bulk/{category.js => category.jsx} (100%) rename src/icons/iconly/bulk/{chart.js => chart.jsx} (100%) rename src/icons/iconly/bulk/{defender.js => defender.jsx} (100%) rename src/icons/iconly/bulk/{document.js => document.jsx} (100%) rename src/icons/iconly/bulk/{exchange.js => exchange.jsx} (100%) rename src/icons/iconly/bulk/{graph.js => graph.jsx} (100%) rename src/icons/iconly/bulk/{intune.js => intune.jsx} (100%) rename src/icons/iconly/bulk/{microsoft.js => microsoft.jsx} (100%) rename src/icons/iconly/bulk/{settings.js => settings.jsx} (100%) rename src/icons/iconly/bulk/{sharepoint.js => sharepoint.jsx} (100%) rename src/icons/iconly/bulk/{show.js => show.jsx} (100%) rename src/icons/iconly/bulk/{teams.js => teams.jsx} (100%) rename src/icons/iconly/bulk/{three-user.js => three-user.jsx} (100%) rename src/icons/iconly/bulk/{ticket.js => ticket.jsx} (100%) rename src/icons/iconly/bulk/{two-user.js => two-user.jsx} (100%) rename src/{index.js => index.jsx} (81%) rename src/layouts/{account-popover.js => account-popover.jsx} (84%) rename src/layouts/{config.js => config.jsx} (91%) rename src/layouts/{footer.js => footer.jsx} (100%) rename src/layouts/{index.js => index.jsx} (94%) rename src/layouts/{mobile-nav-item.js => mobile-nav-item.jsx} (90%) rename src/layouts/{mobile-nav.js => mobile-nav.jsx} (84%) rename src/layouts/{notifications-popover.js => notifications-popover.jsx} (76%) rename src/layouts/{organization-popover.js => organization-popover.jsx} (100%) rename src/layouts/{side-nav-bookmarks.js => side-nav-bookmarks.jsx} (92%) rename src/layouts/{side-nav-item.js => side-nav-item.jsx} (91%) rename src/layouts/{side-nav.js => side-nav.jsx} (91%) rename src/layouts/{top-nav.js => top-nav.jsx} (87%) rename src/pages/{401.js => 401.jsx} (90%) delete mode 100644 src/pages/404.js create mode 100644 src/pages/404.jsx rename src/pages/{500.js => 500.jsx} (94%) rename src/pages/{_app.js => _app.jsx} (93%) rename src/pages/{_document.js => _document.jsx} (98%) rename src/pages/{api-offline.js => api-offline.jsx} (97%) rename src/pages/{authredirect.js => authredirect.jsx} (100%) rename src/pages/cipp/advanced/authentication/cipp-roles/{add.js => add.jsx} (98%) rename src/pages/cipp/advanced/authentication/cipp-roles/{edit.js => edit.jsx} (53%) rename src/pages/cipp/advanced/authentication/cipp-roles/{index.js => index.jsx} (98%) rename src/pages/cipp/advanced/authentication/{cipp-users.js => cipp-users.jsx} (99%) rename src/pages/cipp/advanced/authentication/{sam-app-permissions.js => sam-app-permissions.jsx} (95%) rename src/pages/cipp/advanced/authentication/{sam-app-roles.js => sam-app-roles.jsx} (95%) rename src/pages/cipp/advanced/authentication/{sso.js => sso.jsx} (98%) create mode 100644 src/pages/cipp/advanced/cippdb-cache.jsx rename src/pages/cipp/advanced/container-management/{custom-domains.js => custom-domains.jsx} (98%) rename src/pages/cipp/advanced/container-management/{logs.js => logs.jsx} (95%) rename src/pages/cipp/advanced/container-management/{status.js => status.jsx} (98%) rename src/pages/cipp/advanced/container-management/{worker-health.js => worker-health.jsx} (82%) rename src/pages/cipp/advanced/{diagnostics.js => diagnostics.jsx} (84%) rename src/pages/cipp/advanced/{exchange-cmdlets.js => exchange-cmdlets.jsx} (97%) delete mode 100644 src/pages/cipp/advanced/super-admin/cipp-roles/add.js delete mode 100644 src/pages/cipp/advanced/super-admin/cipp-roles/edit.js delete mode 100644 src/pages/cipp/advanced/super-admin/cipp-roles/index.js rename src/pages/cipp/advanced/super-admin/{function-offloading.js => function-offloading.jsx} (96%) rename src/pages/cipp/advanced/super-admin/{jit-admin-settings.js => jit-admin-settings.jsx} (99%) rename src/pages/cipp/advanced/super-admin/{tenant-mode.js => tenant-mode.jsx} (99%) rename src/pages/cipp/advanced/super-admin/{time-settings.js => time-settings.jsx} (99%) rename src/pages/cipp/advanced/{table-maintenance.js => table-maintenance.jsx} (94%) rename src/pages/cipp/advanced/{timers.js => timers.jsx} (95%) rename src/pages/cipp/custom-data/directory-extensions/{add.js => add.jsx} (99%) rename src/pages/cipp/custom-data/directory-extensions/{index.js => index.jsx} (94%) rename src/pages/cipp/custom-data/mappings/{add.js => add.jsx} (99%) rename src/pages/cipp/custom-data/mappings/{edit.js => edit.jsx} (99%) rename src/pages/cipp/custom-data/mappings/{index.js => index.jsx} (91%) rename src/pages/cipp/custom-data/schema-extensions/{add.js => add.jsx} (91%) rename src/pages/cipp/custom-data/schema-extensions/{index.js => index.jsx} (94%) rename src/pages/cipp/extension-sync/{index.js => index.jsx} (90%) rename src/pages/cipp/integrations/{configure.js => configure.jsx} (96%) rename src/pages/cipp/integrations/{index.js => index.jsx} (84%) rename src/pages/cipp/integrations/{sync.js => sync.jsx} (99%) rename src/pages/cipp/logs/{index.js => index.jsx} (89%) rename src/pages/cipp/logs/{logentry.js => logentry.jsx} (99%) rename src/pages/cipp/{preferences.js => preferences.jsx} (99%) rename src/pages/cipp/scheduler/{index.js => index.jsx} (79%) rename src/pages/cipp/scheduler/{task.js => task.jsx} (99%) delete mode 100644 src/pages/cipp/settings/backend.js create mode 100644 src/pages/cipp/settings/backend.jsx rename src/pages/cipp/settings/{backup.js => backup.jsx} (95%) rename src/pages/cipp/settings/{branding.js => branding.jsx} (98%) rename src/pages/cipp/settings/{features.js => features.jsx} (92%) rename src/pages/cipp/settings/{index.js => index.jsx} (99%) rename src/pages/cipp/settings/{licenses.js => licenses.jsx} (95%) rename src/pages/cipp/settings/{notifications.js => notifications.jsx} (99%) rename src/pages/cipp/settings/{partner-webhooks.js => partner-webhooks.jsx} (90%) rename src/pages/cipp/settings/password-config/{index.js => index.jsx} (78%) rename src/pages/cipp/settings/{permissions.js => permissions.jsx} (99%) rename src/pages/cipp/settings/{siem.js => siem.jsx} (90%) rename src/pages/cipp/settings/{tenants.js => tenants.jsx} (95%) rename src/pages/cipp/{snooze-alert.js => snooze-alert.jsx} (89%) rename src/pages/cipp/statistics/{index.js => index.jsx} (98%) rename src/pages/copilot/agent365/packages/{index.js => index.jsx} (99%) rename src/pages/copilot/reports/copilot-adoption/{index.js => index.jsx} (98%) rename src/pages/copilot/reports/copilot-trend/{index.js => index.jsx} (98%) rename src/pages/copilot/reports/copilot-usage/{index.js => index.jsx} (99%) rename src/pages/copilot/settings/{index.js => index.jsx} (94%) rename src/pages/copilot/shadow-ai/{index.js => index.jsx} (86%) rename src/pages/{dashboardv1.js => dashboardv1.jsx} (99%) rename src/pages/dashboardv2/custom/{index.js => index.jsx} (99%) rename src/pages/dashboardv2/devices/{index.js => index.jsx} (99%) rename src/pages/dashboardv2/identity/{index.js => index.jsx} (99%) rename src/pages/dashboardv2/{index.js => index.jsx} (95%) rename src/pages/email/administration/contacts/{index.js => index.jsx} (94%) rename src/pages/email/administration/deleted-mailboxes/{index.js => index.jsx} (98%) rename src/pages/email/administration/exchange-retention/policies/{index.js => index.jsx} (91%) rename src/pages/email/administration/exchange-retention/tags/{index.js => index.jsx} (92%) rename src/pages/email/administration/hve-accounts/{index.js => index.jsx} (93%) rename src/pages/email/administration/mailbox-rules/{index.js => index.jsx} (95%) rename src/pages/email/administration/mailboxes/{index.js => index.jsx} (90%) rename src/pages/email/administration/quarantine/{files.js => files.jsx} (98%) rename src/pages/email/administration/quarantine/{index.js => index.jsx} (98%) rename src/pages/email/administration/quarantine/{teams.js => teams.jsx} (98%) create mode 100644 src/pages/email/administration/quarantine/user-reported.jsx rename src/pages/email/administration/restricted-users/{index.js => index.jsx} (96%) rename src/pages/email/administration/tenant-allow-block-list-templates/{index.js => index.jsx} (94%) rename src/pages/email/administration/tenant-allow-block-lists/{index.js => index.jsx} (91%) rename src/pages/email/reports/SharedMailboxEnabledAccount/{index.js => index.jsx} (94%) rename src/pages/email/reports/activesync-devices/{index.js => index.jsx} (90%) rename src/pages/email/reports/antiphishing-filters/{index.js => index.jsx} (95%) rename src/pages/email/reports/calendar-permissions/{index.js => index.jsx} (91%) rename src/pages/email/reports/global-address-list/{index.js => index.jsx} (91%) create mode 100644 src/pages/email/reports/mail-flow-statistics/index.jsx rename src/pages/email/reports/mailbox-activity/{index.js => index.jsx} (90%) rename src/pages/email/reports/mailbox-cas-settings/{index.js => index.jsx} (97%) rename src/pages/email/reports/mailbox-forwarding/{index.js => index.jsx} (99%) rename src/pages/email/reports/mailbox-permissions/{index.js => index.jsx} (96%) rename src/pages/email/reports/mailbox-statistics/{index.js => index.jsx} (99%) rename src/pages/email/reports/malware-filters/{index.js => index.jsx} (95%) rename src/pages/email/reports/safeattachments-filters/{index.js => index.jsx} (94%) rename src/pages/email/resources/management/equipment/{index.js => index.jsx} (90%) rename src/pages/email/resources/management/list-rooms/{index.js => index.jsx} (89%) rename src/pages/email/resources/management/room-lists/{index.js => index.jsx} (91%) rename src/pages/email/spamfilter/list-connectionfilter-templates/{index.js => index.jsx} (93%) rename src/pages/email/spamfilter/list-connectionfilter/{index.js => index.jsx} (91%) rename src/pages/email/spamfilter/list-quarantine-policies/{index.js => index.jsx} (94%) rename src/pages/email/spamfilter/list-spamfilter/{index.js => index.jsx} (89%) rename src/pages/email/spamfilter/list-templates/{index.js => index.jsx} (92%) rename src/pages/email/tools/mail-test/{index.js => index.jsx} (97%) rename src/pages/email/tools/mailbox-restore-wizard/{index.js => index.jsx} (97%) rename src/pages/email/tools/mailbox-restores/{index.js => index.jsx} (92%) rename src/pages/email/tools/message-encryption/{index.js => index.jsx} (97%) create mode 100644 src/pages/email/tools/message-trace/historical-search/index.jsx delete mode 100644 src/pages/email/tools/message-trace/index.js create mode 100644 src/pages/email/tools/message-trace/index.jsx create mode 100644 src/pages/email/tools/message-trace/tabOptions.json rename src/pages/email/tools/message-viewer/{index.js => index.jsx} (96%) rename src/pages/email/transport/list-connector-templates/{index.js => index.jsx} (87%) rename src/pages/email/transport/list-connectors/{index.js => index.jsx} (89%) rename src/pages/email/transport/list-rules/{index.js => index.jsx} (91%) rename src/pages/email/transport/list-templates/{index.js => index.jsx} (94%) rename src/pages/endpoint/MEM/approval-requests/{index.js => index.jsx} (96%) rename src/pages/endpoint/MEM/assignment-filter-templates/{deploy.js => deploy.jsx} (99%) rename src/pages/endpoint/MEM/assignment-filter-templates/{index.js => index.jsx} (93%) rename src/pages/endpoint/MEM/assignment-filters/{index.js => index.jsx} (89%) rename src/pages/endpoint/MEM/bitlocker-search/{index.js => index.jsx} (93%) rename src/pages/endpoint/MEM/compare-policies/{index.js => index.jsx} (97%) rename src/pages/endpoint/MEM/devices/{index.js => index.jsx} (81%) rename src/pages/endpoint/MEM/list-appprotection-policies/{index.js => index.jsx} (93%) rename src/pages/endpoint/MEM/list-compliance-policies/{index.js => index.jsx} (93%) rename src/pages/endpoint/MEM/list-policies/{index.js => index.jsx} (93%) rename src/pages/endpoint/MEM/list-templates/{index.js => index.jsx} (89%) rename src/pages/endpoint/MEM/reusable-settings-templates/{index.js => index.jsx} (92%) rename src/pages/endpoint/MEM/reusable-settings/{index.js => index.jsx} (91%) rename src/pages/endpoint/applications/list/{index.js => index.jsx} (94%) rename src/pages/endpoint/applications/queue/{index.js => index.jsx} (91%) rename src/pages/endpoint/applications/templates/{index.js => index.jsx} (91%) rename src/pages/endpoint/autopilot/add-device/{index.js => index.jsx} (99%) rename src/pages/endpoint/autopilot/enrollment-profiles/{android-enterprise.js => android-enterprise.jsx} (98%) rename src/pages/endpoint/autopilot/enrollment-profiles/{apple-ade.js => apple-ade.jsx} (98%) rename src/pages/endpoint/autopilot/enrollment-profiles/{index.js => index.jsx} (98%) rename src/pages/endpoint/autopilot/enrollment-profiles/{windows-autopilot.js => windows-autopilot.jsx} (98%) rename src/pages/endpoint/autopilot/list-devices/{index.js => index.jsx} (94%) rename src/pages/endpoint/autopilot/list-profiles/{index.js => index.jsx} (93%) rename src/pages/endpoint/autopilot/list-status-pages/{index.js => index.jsx} (99%) rename src/pages/endpoint/reports/analyticsdevicescore/{index.js => index.jsx} (93%) rename src/pages/endpoint/reports/autopilot-deployment/{index.js => index.jsx} (93%) rename src/pages/endpoint/reports/detected-apps/{index.js => index.jsx} (99%) rename src/pages/endpoint/reports/workfromanywhere/{index.js => index.jsx} (94%) delete mode 100644 src/pages/fullPageLoading.js create mode 100644 src/pages/fullPageLoading.jsx rename src/pages/identity/administration/deleted-items/{index.js => index.jsx} (92%) rename src/pages/identity/administration/deploy-group-template/{index.js => index.jsx} (97%) rename src/pages/identity/administration/devices/{index.js => index.jsx} (75%) rename src/pages/identity/administration/group-templates/{deploy.js => deploy.jsx} (99%) rename src/pages/identity/administration/group-templates/{index.js => index.jsx} (93%) rename src/pages/identity/administration/groups/{index.js => index.jsx} (85%) rename src/pages/identity/administration/guest-users/{index.js => index.jsx} (67%) rename src/pages/identity/administration/{index.js => index.jsx} (98%) rename src/pages/identity/administration/jit-admin-templates/{index.js => index.jsx} (94%) rename src/pages/identity/administration/jit-admin/{index.js => index.jsx} (92%) create mode 100644 src/pages/identity/administration/jit-role-templates/add.jsx create mode 100644 src/pages/identity/administration/jit-role-templates/edit.jsx create mode 100644 src/pages/identity/administration/jit-role-templates/index.jsx rename src/pages/identity/administration/offboarding-wizard/{index.js => index.jsx} (86%) rename src/pages/identity/administration/risky-users/{index.js => index.jsx} (93%) delete mode 100644 src/pages/identity/administration/roles/index.js create mode 100644 src/pages/identity/administration/roles/index.jsx create mode 100644 src/pages/identity/administration/roles/tabOptions.json create mode 100644 src/pages/identity/administration/roles/templates/add.jsx create mode 100644 src/pages/identity/administration/roles/templates/edit.jsx create mode 100644 src/pages/identity/administration/roles/templates/index.jsx delete mode 100644 src/pages/identity/administration/users/add.jsx delete mode 100644 src/pages/identity/administration/users/bulk-add.js rename src/pages/identity/administration/users/{index.js => index.jsx} (91%) create mode 100644 src/pages/identity/administration/users/tabOptions.json rename src/pages/{tenant/manage/user-defaults.js => identity/administration/users/user-defaults.jsx} (85%) rename src/pages/identity/administration/vacation-mode/add/{index.js => index.jsx} (99%) rename src/pages/identity/administration/vacation-mode/{index.js => index.jsx} (89%) rename src/pages/identity/reports/azure-ad-connect-report/{index.js => index.jsx} (98%) create mode 100644 src/pages/identity/reports/group-usage/index.jsx rename src/pages/identity/reports/inactive-users-report/{index.js => index.jsx} (76%) rename src/pages/{tenant/reports/index.js => identity/reports/index.jsx} (98%) rename src/pages/identity/reports/mfa-report/{index.js => index.jsx} (91%) rename src/pages/identity/reports/risk-detections/{index.js => index.jsx} (95%) rename src/pages/identity/reports/signin-report/{index.js => index.jsx} (96%) rename src/pages/{license.js => license.jsx} (99%) rename src/pages/{loading.js => loading.jsx} (100%) rename src/pages/logout/{index.js => index.jsx} (80%) rename src/pages/{onboardingv2.js => onboardingv2.jsx} (77%) rename src/pages/security/compliance/dlp-templates/{index.js => index.jsx} (93%) rename src/pages/security/compliance/dlp/{index.js => index.jsx} (91%) rename src/pages/security/compliance/labels-templates/{index.js => index.jsx} (93%) rename src/pages/security/compliance/labels/{index.js => index.jsx} (92%) rename src/pages/security/compliance/retention-templates/{index.js => index.jsx} (93%) rename src/pages/security/compliance/retention/{index.js => index.jsx} (87%) rename src/pages/security/compliance/sit-templates/{index.js => index.jsx} (93%) rename src/pages/security/compliance/sit/{index.js => index.jsx} (91%) rename src/pages/security/defender/defender-cve-exceptions/{index.js => index.jsx} (93%) rename src/pages/security/defender/deployment/{index.js => index.jsx} (97%) rename src/pages/security/defender/list-defender-tvm/{index.js => index.jsx} (98%) rename src/pages/security/defender/list-defender/{index.js => index.jsx} (99%) rename src/pages/security/incidents/list-alerts/{index.js => index.jsx} (93%) rename src/pages/security/incidents/list-check-alerts/{index.js => index.jsx} (99%) rename src/pages/security/incidents/list-incidents/{index.js => index.jsx} (91%) rename src/pages/security/incidents/list-mdo-alerts/{index.js => index.jsx} (90%) rename src/pages/security/reports/cve-report/{index.js => index.jsx} (98%) rename src/pages/security/reports/list-device-compliance/{index.js => index.jsx} (98%) rename src/pages/security/reports/mde-onboarding/{index.js => index.jsx} (94%) rename src/pages/teams-share/{deleted-sites.js => deleted-sites.jsx} (86%) rename src/pages/teams-share/{external-users.js => external-users.jsx} (94%) rename src/pages/teams-share/onedrive/{index.js => index.jsx} (83%) rename src/pages/teams-share/permissions-report/{index.js => index.jsx} (92%) rename src/pages/teams-share/sharepoint-templates/{add.js => add.jsx} (95%) rename src/pages/teams-share/sharepoint-templates/{index.js => index.jsx} (89%) rename src/pages/teams-share/sharepoint/{add-site.js => add-site.jsx} (99%) rename src/pages/teams-share/sharepoint/{bulk-add-site.js => bulk-add-site.jsx} (99%) rename src/pages/teams-share/sharepoint/{index.js => index.jsx} (84%) delete mode 100644 src/pages/teams-share/sharepoint2/index.js create mode 100644 src/pages/teams-share/sharepoint2/index.jsx rename src/pages/teams-share/sharing-report/{index.js => index.jsx} (93%) create mode 100644 src/pages/teams-share/storage-report/index.jsx rename src/pages/teams-share/teams/business-voice/{index.js => index.jsx} (95%) rename src/pages/teams-share/teams/list-team/{index.js => index.jsx} (86%) rename src/pages/teams-share/teams/teams-activity/{index.js => index.jsx} (99%) rename src/pages/tenant/administration/alert-configuration/{index.js => index.jsx} (84%) rename src/pages/tenant/administration/alert-configuration/{snoozed-alerts.js => snoozed-alerts.jsx} (92%) rename src/pages/tenant/administration/app-consent-requests/{index.js => index.jsx} (93%) rename src/pages/tenant/administration/applications/{app-registrations.js => app-registrations.jsx} (91%) rename src/pages/tenant/administration/applications/{enterprise-apps.js => enterprise-apps.jsx} (73%) rename src/pages/tenant/administration/applications/permission-sets/{add.js => add.jsx} (97%) rename src/pages/tenant/administration/applications/permission-sets/{edit.js => edit.jsx} (99%) rename src/pages/tenant/administration/applications/permission-sets/{index.js => index.jsx} (93%) rename src/pages/tenant/administration/applications/templates/{add.js => add.jsx} (99%) rename src/pages/tenant/administration/applications/templates/{edit.js => edit.jsx} (99%) rename src/pages/tenant/administration/applications/templates/{index.js => index.jsx} (95%) rename src/pages/tenant/administration/audit-logs/{coverage.js => coverage.jsx} (86%) rename src/pages/tenant/administration/audit-logs/{directory-audits.js => directory-audits.jsx} (89%) rename src/pages/tenant/administration/audit-logs/{index.js => index.jsx} (79%) rename src/pages/tenant/administration/audit-logs/{log.js => log.jsx} (98%) rename src/pages/tenant/administration/audit-logs/{manual-searches.js => manual-searches.jsx} (90%) rename src/pages/tenant/administration/audit-logs/{search-results.js => search-results.jsx} (94%) rename src/pages/tenant/administration/audit-logs/{searches.js => searches.jsx} (97%) rename src/pages/tenant/administration/authentication-methods/{index.js => index.jsx} (97%) rename src/pages/tenant/administration/authentication-methods/{registration-campaign.js => registration-campaign.jsx} (97%) rename src/pages/tenant/administration/domains/{index.js => index.jsx} (95%) rename src/pages/tenant/administration/{index.js => index.jsx} (98%) rename src/pages/tenant/administration/partner-relationships/{index.js => index.jsx} (99%) rename src/pages/tenant/administration/securescore/{index.js => index.jsx} (97%) rename src/pages/tenant/administration/securescore/{table.js => table.jsx} (89%) rename src/pages/tenant/administration/tenantlookup/{index.js => index.jsx} (97%) rename src/pages/tenant/administration/tenants/{add.js => add.jsx} (84%) rename src/pages/tenant/administration/tenants/{edit.js => edit.jsx} (90%) rename src/pages/tenant/administration/tenants/{global-variables.js => global-variables.jsx} (98%) rename src/pages/tenant/administration/tenants/groups/{edit.js => edit.jsx} (99%) rename src/pages/tenant/administration/tenants/groups/{index.js => index.jsx} (92%) rename src/pages/tenant/administration/tenants/{index.js => index.jsx} (88%) rename src/pages/tenant/backup/backup-wizard/{index.js => index.jsx} (91%) rename src/pages/tenant/baselines/alignment/{index.js => index.jsx} (87%) rename src/pages/tenant/baselines/{index.js => index.jsx} (93%) rename src/pages/tenant/baselines/templates/{index.js => index.jsx} (89%) delete mode 100644 src/pages/tenant/conditional/deploy-vacation/add.jsx delete mode 100644 src/pages/tenant/conditional/deploy-vacation/index.js rename src/pages/tenant/conditional/list-named-locations/{index.js => index.jsx} (94%) rename src/pages/tenant/conditional/list-policies/{index.js => index.jsx} (91%) rename src/pages/tenant/conditional/list-template/{index.js => index.jsx} (86%) rename src/pages/tenant/gdap-management/{index.js => index.jsx} (77%) rename src/pages/tenant/gdap-management/invites/{add.js => add.jsx} (91%) rename src/pages/tenant/gdap-management/invites/{index.js => index.jsx} (90%) rename src/pages/tenant/gdap-management/{offboarding.js => offboarding.jsx} (96%) rename src/pages/tenant/gdap-management/onboarding/{index.js => index.jsx} (83%) rename src/pages/tenant/gdap-management/onboarding/{start.js => start.jsx} (98%) rename src/pages/tenant/gdap-management/relationships/{index.js => index.jsx} (91%) rename src/pages/tenant/gdap-management/relationships/relationship/{index.js => index.jsx} (98%) rename src/pages/tenant/gdap-management/relationships/relationship/{mappings.js => mappings.jsx} (95%) rename src/pages/tenant/gdap-management/role-templates/{add.js => add.jsx} (57%) delete mode 100644 src/pages/tenant/gdap-management/role-templates/edit.js create mode 100644 src/pages/tenant/gdap-management/role-templates/edit.jsx delete mode 100644 src/pages/tenant/gdap-management/role-templates/index.js create mode 100644 src/pages/tenant/gdap-management/role-templates/index.jsx create mode 100644 src/pages/tenant/gdap-management/role-templates/mappings.jsx delete mode 100644 src/pages/tenant/gdap-management/roles/add.js create mode 100644 src/pages/tenant/gdap-management/roles/add.jsx delete mode 100644 src/pages/tenant/gdap-management/roles/index.js create mode 100644 src/pages/tenant/gdap-management/roles/index.jsx rename src/pages/tenant/manage/{applied-standards.js => applied-standards.jsx} (97%) rename src/pages/tenant/manage/{configuration-backup.js => configuration-backup.jsx} (92%) rename src/pages/tenant/manage/{drift.js => drift.jsx} (91%) rename src/pages/tenant/manage/{edit.js => edit.jsx} (95%) rename src/pages/tenant/manage/{history.js => history.jsx} (79%) rename src/pages/tenant/manage/{policies-deployed.js => policies-deployed.jsx} (95%) rename src/pages/tenant/reports/application-consent/{index.js => index.jsx} (99%) rename src/pages/tenant/reports/custom-test-report/{index.js => index.jsx} (98%) rename src/pages/tenant/reports/graph-office-reports/{index.js => index.jsx} (95%) rename src/pages/{identity/reports/index.js => tenant/reports/index.jsx} (98%) rename src/pages/tenant/reports/list-licenses/{index.js => index.jsx} (83%) create mode 100644 src/pages/tenant/reports/list-licenses/optimization.jsx create mode 100644 src/pages/tenant/reports/list-licenses/pricing.jsx create mode 100644 src/pages/tenant/reports/list-licenses/tabOptions.json rename src/pages/tenant/standards/alignment/{index.js => index.jsx} (94%) rename src/pages/tenant/standards/bpa-report/{builder.js => builder.jsx} (98%) rename src/pages/tenant/standards/bpa-report/{index.js => index.jsx} (86%) rename src/pages/tenant/standards/bpa-report/{view.js => view.jsx} (96%) rename src/pages/tenant/standards/domains-analyser/{index.js => index.jsx} (76%) rename src/pages/tenant/standards/templates/{index.js => index.jsx} (91%) rename src/pages/tenant/tools/appapproval/{index.js => index.jsx} (99%) rename src/pages/tenant/tools/bpa-report-builder/{index.js => index.jsx} (97%) rename src/pages/tenant/tools/geoiplookup/{index.js => index.jsx} (94%) rename src/pages/tenant/tools/graph-explorer/{index.js => index.jsx} (97%) rename src/pages/tenant/tools/individual-domains/{index.js => index.jsx} (98%) rename src/pages/tenant/tools/tenantlookup/{index.js => index.jsx} (97%) rename src/pages/tools/breachlookup/{index.js => index.jsx} (97%) rename src/pages/tools/community-repos/{catalog.js => catalog.jsx} (88%) rename src/pages/tools/community-repos/{index.js => index.jsx} (91%) rename src/pages/tools/community-repos/{repo.js => repo.jsx} (100%) rename src/pages/tools/report-builder/builder/{index.js => index.jsx} (91%) rename src/pages/tools/report-builder/generated/{index.js => index.jsx} (79%) rename src/pages/tools/report-builder/templates/{index.js => index.jsx} (93%) rename src/pages/tools/report-builder/view/{index.js => index.jsx} (89%) create mode 100644 src/pages/tools/template-packages/index.jsx rename src/pages/tools/tenantbreachlookup/{index.js => index.jsx} (89%) rename src/pages/{unauthenticated.js => unauthenticated.jsx} (74%) delete mode 100644 src/sections/dashboard/account/account-2fa.js delete mode 100644 src/sections/dashboard/account/account-details.js delete mode 100644 src/sections/dashboard/account/account-password.js delete mode 100644 src/sections/dashboard/calendar/calendar-container.js delete mode 100644 src/sections/dashboard/calendar/calendar-event-dialog.js delete mode 100644 src/sections/dashboard/calendar/calendar-toolbar.js delete mode 100644 src/sections/dashboard/components/card-headings/card-heading-1.js delete mode 100644 src/sections/dashboard/components/card-headings/card-heading-2.js delete mode 100644 src/sections/dashboard/components/card-headings/card-heading-3.js delete mode 100644 src/sections/dashboard/components/data-states/data-state-1.js delete mode 100644 src/sections/dashboard/components/data-states/data-state-2.js delete mode 100644 src/sections/dashboard/components/data-states/data-state-3.js delete mode 100644 src/sections/dashboard/components/images-uploader.js delete mode 100644 src/sections/dashboard/components/lists/list-1.js delete mode 100644 src/sections/dashboard/components/lists/list-2.js delete mode 100644 src/sections/dashboard/components/lists/list-3.js delete mode 100644 src/sections/dashboard/components/lists/list-4.js delete mode 100644 src/sections/dashboard/components/lists/list-5.js delete mode 100644 src/sections/dashboard/components/onboarding/wizard.js delete mode 100644 src/sections/dashboard/components/page-headings/page-heading-1.js delete mode 100644 src/sections/dashboard/components/page-headings/page-heading-2.js delete mode 100644 src/sections/dashboard/components/page-headings/page-heading-3.js delete mode 100644 src/sections/dashboard/components/stats/stats-1.js delete mode 100644 src/sections/dashboard/components/stats/stats-2.js delete mode 100644 src/sections/dashboard/customers/customer-details.js delete mode 100644 src/sections/dashboard/customers/customer-dialog.js delete mode 100644 src/sections/dashboard/customers/customer-latest-orders.js delete mode 100644 src/sections/dashboard/customers/customer-log.js delete mode 100644 src/sections/dashboard/customers/customer-note-add.js delete mode 100644 src/sections/dashboard/customers/customer-note-card.js delete mode 100644 src/sections/dashboard/customers/customer-notes.js delete mode 100644 src/sections/dashboard/customers/customer-order-menu.js delete mode 100644 src/sections/dashboard/customers/customer-orders-table.js delete mode 100644 src/sections/dashboard/customers/customer-properties.js delete mode 100644 src/sections/dashboard/customers/customers-search.js delete mode 100644 src/sections/dashboard/customers/customers-table-menu.js delete mode 100644 src/sections/dashboard/customers/customers-table.js delete mode 100644 src/sections/dashboard/foundation/inputs/input-1.js delete mode 100644 src/sections/dashboard/foundation/inputs/input-2.js delete mode 100644 src/sections/dashboard/foundation/inputs/input-3.js delete mode 100644 src/sections/dashboard/foundation/inputs/input-4.js delete mode 100644 src/sections/dashboard/foundation/inputs/input-5.js delete mode 100644 src/sections/dashboard/foundation/tables/table-1.js delete mode 100644 src/sections/dashboard/invoices/invoice-details.js delete mode 100644 src/sections/dashboard/invoices/invoice-line-items.js delete mode 100644 src/sections/dashboard/invoices/invoice-payment-history.js delete mode 100644 src/sections/dashboard/invoices/invoice-payment.js delete mode 100644 src/sections/dashboard/invoices/invoice-pdf-document.js delete mode 100644 src/sections/dashboard/invoices/invoice-pdf-preview.js delete mode 100644 src/sections/dashboard/invoices/invoice-quick-actions.js delete mode 100644 src/sections/dashboard/invoices/invoices-search.js delete mode 100644 src/sections/dashboard/invoices/invoices-stats.js delete mode 100644 src/sections/dashboard/invoices/invoices-table-menu.js delete mode 100644 src/sections/dashboard/invoices/invoices-table.js delete mode 100644 src/sections/dashboard/orders/order-create-dialog.js delete mode 100644 src/sections/dashboard/orders/order-details-dialog.js delete mode 100644 src/sections/dashboard/orders/order-details.js delete mode 100644 src/sections/dashboard/orders/order-line-items.js delete mode 100644 src/sections/dashboard/orders/order-payment-dialog.js delete mode 100644 src/sections/dashboard/orders/order-payment.js delete mode 100644 src/sections/dashboard/orders/order-quick-actions.js delete mode 100644 src/sections/dashboard/orders/orders-dnd-draggable.js delete mode 100644 src/sections/dashboard/orders/orders-dnd-droppable.js delete mode 100644 src/sections/dashboard/orders/orders-dnd.js delete mode 100644 src/sections/dashboard/orders/orders-search.js delete mode 100644 src/sections/dashboard/orders/orders-table-menu.js delete mode 100644 src/sections/dashboard/orders/orders-table.js delete mode 100644 src/sections/dashboard/organization/organization-billing-details.js delete mode 100644 src/sections/dashboard/organization/organization-billing-plan.js delete mode 100644 src/sections/dashboard/organization/organization-invite-dialog.js delete mode 100644 src/sections/dashboard/overview/overview-account-setup.js delete mode 100644 src/sections/dashboard/overview/overview-bills.js delete mode 100644 src/sections/dashboard/overview/overview-latest-customers.js delete mode 100644 src/sections/dashboard/overview/overview-notifications.js delete mode 100644 src/sections/dashboard/overview/overview-orders-summary.js delete mode 100644 src/sections/dashboard/overview/overview-summary.js delete mode 100644 src/sections/dashboard/products/product-Insights-reviews.js delete mode 100644 src/sections/dashboard/products/product-Insights-sales.js delete mode 100644 src/sections/dashboard/products/product-create-dialog.js delete mode 100644 src/sections/dashboard/products/product-details-dialog.js delete mode 100644 src/sections/dashboard/products/product-details.js delete mode 100644 src/sections/dashboard/products/product-insights-channel.js delete mode 100644 src/sections/dashboard/products/product-insights-rate.js delete mode 100644 src/sections/dashboard/products/product-quick-actions.js delete mode 100644 src/sections/dashboard/products/product-variant-dialog.js delete mode 100644 src/sections/dashboard/products/product-variants-table.js delete mode 100644 src/sections/dashboard/products/product-variants.js delete mode 100644 src/sections/dashboard/products/products-search.js delete mode 100644 src/sections/dashboard/products/products-stats.js delete mode 100644 src/sections/dashboard/products/products-table-menu.js delete mode 100644 src/sections/dashboard/products/products-table.js delete mode 100644 src/sections/dashboard/sales/sales-breakdown.js delete mode 100644 src/sections/dashboard/sales/sales-kpi.js rename src/theme/base/{create-components.js => create-components.jsx} (98%) rename src/theme/dark/{create-components.js => create-components.jsx} (80%) rename src/theme/light/{create-components.js => create-components.jsx} (100%) create mode 100644 src/utils/cippdb-cache-categories.js create mode 100644 src/utils/gdap-role-options.js rename src/utils/{get-cipp-formatting.js => get-cipp-formatting.jsx} (87%) delete mode 100644 src/utils/icon-registry.js create mode 100644 src/utils/icon-registry.jsx rename src/utils/{permissions.js => permissions.jsx} (94%) create mode 100644 src/utils/route-redirects.js create mode 100644 src/utils/shadow-ai.js create mode 100644 src/utils/sharepoint-browser-recents.js create mode 100644 src/utils/sharepoint-library-copy-eligible.js create mode 100644 src/utils/sharepoint-recycle-bin-tree.js create mode 100644 src/utils/storage-cleanup-opportunities.js create mode 100644 tests/api/ApiCall.retry.test.jsx create mode 100644 tests/components/CippComponents/CippFormComponentNumber.test.jsx create mode 100644 tests/components/CippComponents/CippIntuneDeviceActions.test.jsx create mode 100644 tests/components/CippComponents/CippSchedulerCountdown.test.jsx create mode 100644 tests/components/CippComponents/CippUserActions.ooo.test.jsx create mode 100644 tests/components/CippComponents/CippUserReportedMessagesTable.test.jsx create mode 100644 tests/components/CippComponents/SetupGatePage.test.jsx create mode 100644 tests/components/CippSettings/CippRoleAddEdit.test.jsx create mode 100644 tests/components/CippTable/util-row-open.test.js create mode 100644 tests/components/CippTable/util-row-text-interaction.test.js create mode 100644 tests/components/CippWizard/CippWizardOffboarding.test.jsx create mode 100644 tests/components/MailFlowReportButton.test.jsx rename tests/mocks/{next-dynamic.js => next-dynamic.jsx} (100%) delete mode 100644 tests/mocks/tiptap-extension-image.js create mode 100644 tests/pages/GroupViewPage.bulkRefetch.test.jsx create mode 100644 tests/pages/MessageTracePage.errorSurfacing.test.jsx create mode 100644 tests/pages/ReportBuilderPage.scheduleTemplate.test.jsx create mode 100644 tests/pages/ShadowAIPage.sortByRiskSeverity.test.jsx create mode 100644 tests/utils/gdap-role-options.test.js create mode 100644 tests/utils/help-links.test.js diff --git a/.storybook/main.mjs b/.storybook/main.mjs index 605b66065a98..ccb00cfd9d8b 100644 --- a/.storybook/main.mjs +++ b/.storybook/main.mjs @@ -46,12 +46,7 @@ const config = { // lightweight mocks so components render without a Next.js runtime. alias: { ...config.resolve?.alias, - // stub for mui-tiptap's uninstalled required peer @tiptap/extension-image - '@tiptap/extension-image': path.resolve( - dirname, - '../tests/mocks/tiptap-extension-image.js', - ), - 'next/dynamic': path.resolve(dirname, '../tests/mocks/next-dynamic.js'), + 'next/dynamic': path.resolve(dirname, '../tests/mocks/next-dynamic.jsx'), 'next/router': path.resolve(dirname, '../tests/mocks/next-router.js'), 'next/navigation': path.resolve(dirname, '../tests/mocks/next-navigation.js'), 'next/head': path.resolve(dirname, '../tests/mocks/next-head.js'), @@ -66,16 +61,6 @@ const config = { 'process.env': '{}', global: 'window', }, - esbuild: { - ...config.esbuild, - // The codebase uses .js files with JSX syntax. Vite's default esbuild loader - // only handles JSX in .jsx files, so we override the loader for all .js files. - jsx: 'automatic', - jsxImportSource: 'react', - loader: 'jsx', - include: /(src|tests|\.storybook)\/.*\.(js|jsx)$/, - exclude: [], - }, build: { ...config.build, rollupOptions: { @@ -89,20 +74,6 @@ const config = { }, }, }, - optimizeDeps: { - ...config.optimizeDeps, - // Same JSX-in-.js fix as above, but for Vite's dependency pre-bundling step - // (esbuild runs separately for optimizeDeps vs transform). - esbuildOptions: { - ...config.optimizeDeps?.esbuildOptions, - jsx: 'automatic', - jsxImportSource: 'react', - loader: { - '.js': 'jsx', - '.jsx': 'jsx', - }, - }, - }, } }, staticDirs: ['../public'], diff --git a/.storybook/vitest.setup.js b/.storybook/vitest.setup.js index b26d0bf2f15b..24e57e4b0f06 100644 --- a/.storybook/vitest.setup.js +++ b/.storybook/vitest.setup.js @@ -1,5 +1,6 @@ import { configure } from 'storybook/test' import '../tests/mocks/require-context' -// coverage instrumentation slows lazy chunks and fetches past the 1s default -configure({ asyncUtilTimeout: 10000 }) +// coverage instrumentation and vite 8's slower module serving push lazy chunks +// and fetches past the 1s default (and past 10s when the full suite runs) +configure({ asyncUtilTimeout: 30000 }) diff --git a/eslint.config.mjs b/eslint.config.mjs index fd4045f93c13..69269d559271 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,6 +22,9 @@ const eslintConfig = defineConfig([ maximum: 10, }, ], + // JSX must live in .jsx files: vite 8's oxc transform (vitest/storybook) + // decides JSX handling purely by file extension + 'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }], }, }, { diff --git a/package.json b/package.json index 6fadedda3a77..067c5e36c592 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "license": "AGPL-3.0", "engines": { - "node": "^22.22.0" + "node": "^22.22.2" }, "repository": { "type": "git", @@ -38,36 +38,38 @@ "@emotion/react": "11.14.0", "@emotion/server": "11.11.0", "@emotion/styled": "11.14.1", + "@floating-ui/dom": "1.7.6", "@heroicons/react": "2.2.0", "@monaco-editor/react": "^4.6.0", - "@mui/icons-material": "7.3.10", - "@mui/lab": "7.0.0-beta.17", - "@mui/material": "7.3.10", - "@mui/system": "7.3.10", - "@mui/x-date-pickers": "^9.10.1", + "@mui/icons-material": "9.4.0", + "@mui/lab": "9.0.0-beta.9", + "@mui/material": "9.4.0", + "@mui/system": "9.4.0", + "@mui/x-date-pickers": "^9.12.0", "@musement/iso-duration": "^1.0.0", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", - "@react-pdf/renderer": "^4.5.1", + "@react-pdf/renderer": "4.5.1", "@reduxjs/toolkit": "^2.12.0", - "@tanstack/query-sync-storage-persister": "^5.90.25", + "@tanstack/query-sync-storage-persister": "^5.102.8", "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", "@tanstack/react-query-persist-client": "^5.101.2", "@tanstack/react-table": "^8.19.2", - "@tiptap/core": "^3.29.2", - "@tiptap/extension-heading": "^3.27.3", - "@tiptap/extension-table": "^3.20.5", - "@tiptap/pm": "^3.29.2", - "@tiptap/react": "^3.20.5", - "@tiptap/starter-kit": "^3.29.2", + "@tiptap/core": "^3.31.3", + "@tiptap/extension-heading": "^3.31.3", + "@tiptap/extension-image": "^3.31.3", + "@tiptap/extension-table": "^3.31.3", + "@tiptap/pm": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "@vvo/tzdb": "^6.198.0", "apexcharts": "6.6.1", "axios": "1.18.1", "date-fns": "4.4.0", "diff": "^9.0.0", "dompurify": "^3.4.12", - "driver.js": "^1.4.0", + "driver.js": "^1.8.0", "eml-parse-js": "^1.2.0-beta.0", "export-to-csv": "^1.3.0", "formik": "2.4.9", @@ -94,14 +96,13 @@ "react-dropzone": "20.0.0", "react-error-boundary": "^6.1.2", "react-hook-form": "^7.76.1", - "react-hot-toast": "2.6.0", "react-html-parser": "^2.0.2", + "react-is": "19.2.8", "react-leaflet": "5.0.0", "react-leaflet-markercluster": "^5.0.0-rc.0", "react-markdown": "10.1.0", "react-media-hook": "^0.5.0", "react-papaparse": "^4.4.0", - "react-quill": "^2.0.0", "react-redux": "9.3.0", "react-syntax-highlighter": "^16.1.0", "react-time-ago": "^7.3.3", @@ -114,6 +115,7 @@ "remark-parse": "^11.0.0", "simplebar": "6.3.3", "simplebar-react": "3.3.2", + "stylis": "4.2.0", "stylis-plugin-rtl": "2.1.1", "swagger-ui-dist": "5.32.12", "unified": "^11.0.5", @@ -130,19 +132,21 @@ "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.5", "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "4.1.10", "eslint": "^9.39.4", "eslint-config-next": "^16.2.10", "eslint-config-prettier": "^10.1.8", - "jsdom": "29.0.1", - "msw": "2.12.14", + "jsdom": "30.0.1", + "msw": "2.15.0", "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", "prettier": "^3.9.6", - "storybook": "10.5.7", + "storybook": "10.5.10", "typescript": "5.9.3", - "vite": "7.3.6", + "vite": "8.2.2", "vitest": "4.1.10" }, "msw": { @@ -151,6 +155,9 @@ ] }, "resolutions": { - "vite": "7.3.6" + "vite": "8.2.2", + "postcss": "^8.5.23", + "sharp": "^0.35.0", + "monaco-editor/dompurify": "^3.4.14" } } diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index b17fcd650c0a..0c970efc963c 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -7,8 +7,8 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.12.14' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() @@ -137,8 +137,18 @@ async function handleRequest(event, requestId, requestInterceptedAt) { if (client && activeClientIds.has(client.id)) { const serializedRequest = await serializeRequest(requestCloneForEvents) + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() + const responseClone = isEventStreamResponse ? null : response.clone() sendToClient( client, @@ -151,15 +161,17 @@ async function handleRequest(event, requestId, requestInterceptedAt) { ...serializedRequest, }, response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, }, }, }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], ) } diff --git a/public/version.json b/public/version.json index 0453cde03069..d8df8939c3a4 100644 --- a/public/version.json +++ b/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.9.1" -} \ No newline at end of file + "version": "10.10.0" +} diff --git a/src/api/ApiCall.jsx b/src/api/ApiCall.jsx index 1a74d97210fe..08c492129a0e 100644 --- a/src/api/ApiCall.jsx +++ b/src/api/ApiCall.jsx @@ -11,6 +11,20 @@ const wildcardToRegExp = (pattern) => new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`); const matchesWildcardPattern = (queryKey, pattern) => wildcardToRegExp(pattern).test(queryKey); +// The server's Retry-After (seconds) as ms, capped so a large hint can't hang a request indefinitely. +const getRetryAfterMs = (error) => { + if (!isAxiosError(error)) return null; + const headers = error.response?.headers; + const raw = headers?.get?.("retry-after") ?? headers?.["retry-after"]; + const seconds = Number(raw); + return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1000, 60000) : null; +}; + +// react-query's default exponential backoff, but honouring the server's Retry-After when present so a +// throttled retry lands after the limit clears instead of hammering inside the window. +const retryDelayWithRetryAfter = (failureCount, error) => + getRetryAfterMs(error) ?? Math.min(1000 * 2 ** failureCount, 30000); + export function ApiGetCall(props) { const { url, @@ -74,6 +88,7 @@ export function ApiGetCall(props) { signal: signal, params: { ...element, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), + cippQueryKey: queryKey, }); results.push(response.data); if (onResult) { @@ -113,6 +128,7 @@ export function ApiGetCall(props) { params: { ...data, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), responseType: responseType, + cippQueryKey: queryKey, }); let responseData = response.data; @@ -165,6 +181,7 @@ export function ApiGetCall(props) { keepPreviousData: keepPreviousData, refetchInterval: refetchInterval, retry: retryFn, + retryDelay: retryDelayWithRetryAfter, }); return queryInfo; } @@ -295,14 +312,16 @@ export function ApiGetCallWithPagination({ signal: signal, params: { ...data, ...pageParam, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), + cippQueryKey: queryKey, }); return response.data; }, getNextPageParam: (lastPage) => { + // AllTenants pages only when the page opted into manualPagination. if ( data?.noPagination || data?.manualPagination === false || - data?.tenantFilter === "AllTenants" + (data?.tenantFilter === "AllTenants" && data?.manualPagination !== true) ) { return undefined; } @@ -311,6 +330,7 @@ export function ApiGetCallWithPagination({ staleTime: 300000, refetchOnWindowFocus: false, retry: retryFn, + retryDelay: retryDelayWithRetryAfter, }); return queryInfo; diff --git a/src/components/BECRemediationReportButton.js b/src/components/BECRemediationReportButton.jsx similarity index 99% rename from src/components/BECRemediationReportButton.js rename to src/components/BECRemediationReportButton.jsx index 5c33c927c1d9..7cb7b06a2c9e 100644 --- a/src/components/BECRemediationReportButton.js +++ b/src/components/BECRemediationReportButton.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { CippIcons } from '../utils/icon-registry' import { Button, Tooltip, @@ -11,7 +12,6 @@ import { IconButton, CircularProgress, } from '@mui/material' -import { PictureAsPdf, Download, Close } from '@mui/icons-material' import { PDFDownloadLink } from '@react-pdf/renderer' import { CippPdfPreview } from './CippPdf/CippPdfPreview' import { useReportVariables } from './CippPdf/useReportVariables' @@ -49,7 +49,6 @@ export const BECRemediationReportDocument = ({ day: 'numeric', }) - // Helper function to format dates const formatDate = (dateString) => { if (!dateString) return 'N/A' @@ -1237,7 +1236,7 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) => } > - + {secureScore.average}% - + across {secureScore.scored} tenants @@ -170,7 +180,9 @@ export const AllTenantsDashboard = () => { {secureScore.delta > 0 ? '▲' : secureScore.delta < 0 ? '▼' : '■'}{' '} {Math.abs(secureScore.delta)} pts - + over {secureScore.trend.length} days @@ -188,9 +200,16 @@ export const AllTenantsDashboard = () => { } /> - + - + Best @@ -207,7 +226,9 @@ export const AllTenantsDashboard = () => { - + Worst @@ -227,7 +248,9 @@ export const AllTenantsDashboard = () => { ) : ( !secureScoreApi.isLoading && ( - + No cached secure scores yet. ) @@ -257,7 +280,9 @@ export const AllTenantsDashboard = () => { > {tests.identityTenantCount} - + of {tenantCount} tenants failing
an identity check @@ -266,15 +291,19 @@ export const AllTenantsDashboard = () => {
{tests.identityRows.length > 0 ? ( - + Most widespread {tests.identityRows.map((row) => ( {row.label} @@ -293,7 +322,9 @@ export const AllTenantsDashboard = () => { ) : ( !failedTestsApi.isLoading && ( - + No failing identity checks. ) @@ -316,7 +347,9 @@ export const AllTenantsDashboard = () => { ) : ( !domainsApi.isLoading && ( - + No analysed domains yet. ) @@ -334,12 +367,20 @@ export const AllTenantsDashboard = () => { } > - + {alignment.average}% - + portfolio average @@ -356,7 +397,9 @@ export const AllTenantsDashboard = () => { /> {alignment.lowest.length > 0 && ( - + Lowest scoring @@ -364,8 +407,10 @@ export const AllTenantsDashboard = () => { {item.name} @@ -513,7 +558,9 @@ export const AllTenantsDashboard = () => { {delegation.noAutoExtendSoon} expiring without auto-extend - + {delegation.urgent .filter((item) => !item.hasAutoExtend) .slice(0, 3) @@ -544,7 +591,9 @@ export const AllTenantsDashboard = () => { > {cache.freshness.fresh} - + fresh @@ -558,7 +607,9 @@ export const AllTenantsDashboard = () => { > {cache.freshness.stale} - + stale @@ -572,7 +623,9 @@ export const AllTenantsDashboard = () => { > {cache.freshness.missing} - + never cached @@ -591,5 +644,5 @@ export const AllTenantsDashboard = () => { - ) + ); } diff --git a/src/components/CippAllTenants/AllTenantsPrimitives.jsx b/src/components/CippAllTenants/AllTenantsPrimitives.jsx index a36ed93b648d..05b6f5fc5bcc 100644 --- a/src/components/CippAllTenants/AllTenantsPrimitives.jsx +++ b/src/components/CippAllTenants/AllTenantsPrimitives.jsx @@ -10,7 +10,7 @@ import { Tooltip, Typography, } from '@mui/material' -import { ExpandMore } from '@mui/icons-material' +import { CippIcons } from '../../utils/icon-registry' import { useTheme } from '@mui/material/styles' import Link from 'next/link' import { useState } from 'react' @@ -81,11 +81,22 @@ export const AllTenantsStatTile = ({ > {isFetching ? : value}
- + {isFetching ? : label} {(meta || isFetching) && ( - + {isFetching ? : meta} )} @@ -118,10 +129,16 @@ export const AllTenantsBarList = ({ rows = [], max, isFetching, emptyText = 'No if (!rows.length) { return ( - + {emptyText} - ) + ); } const scale = max ?? Math.max(...rows.map((row) => row.total ?? 0), 1) @@ -138,7 +155,9 @@ export const AllTenantsBarList = ({ rows = [], max, isFetching, emptyText = 'No alignItems: 'center', }} > - + {row.label} + {emptyText} - ) + ); } return ( @@ -206,17 +231,16 @@ export const AllTenantsRowList = ({ rows = [], isFetching, emptyText = 'Nothing + backgroundColor: 'action.hover' + }}> {row.name} @@ -228,9 +252,11 @@ export const AllTenantsRowList = ({ rows = [], isFetching, emptyText = 'Nothing {row.detail} @@ -248,7 +274,7 @@ export const AllTenantsRowList = ({ rows = [], isFetching, emptyText = 'Nothing ))}
- ) + ); } // Same 72-hour boundary the summary line uses, so a row reading "Oldest collection 60 hours old" @@ -287,10 +313,16 @@ export const AllTenantsCacheList = ({ if (!rows.length) { return ( - + {emptyText} - ) + ); } return ( @@ -304,10 +336,13 @@ export const AllTenantsCacheList = ({ const header = ( + sx={{ + alignItems: "center", + px: 1.25, + py: 1.25, + width: '100%' + }}> {row.name} @@ -316,9 +351,11 @@ export const AllTenantsCacheList = ({ {row.detail} @@ -333,7 +370,7 @@ export const AllTenantsCacheList = ({ label={`${collections.length} stale`} sx={{ flexShrink: 0 }} /> - + sx={{ + alignItems: "baseline", + justifyContent: 'space-between' + }}> + sx={{ + color: "text.secondary", + flexShrink: 0, + fontVariantNumeric: 'tabular-nums' + }}> {parseCippDate(collection.lastRefresh).toLocaleString()} ·{' '} {formatAge(collection.ageHours)} @@ -401,10 +441,10 @@ export const AllTenantsCacheList = ({ - ) + ); })}
- ) + ); } /** Labelled percentage meters, for pass-rate style measures. */ @@ -423,8 +463,15 @@ export const AllTenantsMeterList = ({ meters = [], isFetching }) => { {meters.map((meter) => ( - - + + {meter.label} { }} /> {meter.caption && ( - + {meter.caption} )} ))} - ) + ); } /** @@ -491,11 +540,13 @@ export const AllTenantsTrendChart = ({ justifyContent: 'center', }} > - + Not enough history yet for a trend - ) + ); } const values = points.map((point) => point.percent) @@ -552,20 +603,22 @@ export const AllTenantsTrendChart = ({ /** Band heading that separates the dashboard into Portfolio / Security / Operations. */ export const AllTenantsBandHeading = ({ title, description }) => ( - + flexWrap: 'wrap' + }}> {title} - + {description} diff --git a/src/components/CippAllTenants/AllTenantsSecureScore.jsx b/src/components/CippAllTenants/AllTenantsSecureScore.jsx index 65fb1c01fb1e..b02598576fec 100644 --- a/src/components/CippAllTenants/AllTenantsSecureScore.jsx +++ b/src/components/CippAllTenants/AllTenantsSecureScore.jsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Box, Card, @@ -12,14 +13,6 @@ import { } from '@mui/material' import { Grid } from '@mui/system' import Link from 'next/link' -import { - ArrowTrendingDownIcon, - ArrowTrendingUpIcon, - EyeIcon, - ExclamationTriangleIcon, - ShieldCheckIcon, - TrophyIcon, -} from '@heroicons/react/24/outline' import { ApiGetCall } from '../../api/ApiCall.jsx' import { getCippError } from '../../utils/get-cipp-error' import { CippInfoBar } from '../CippCards/CippInfoBar' @@ -57,9 +50,10 @@ const LeaderboardCard = ({ title, subheader, rows, isFetching }) => ( + slotProps={{ + title: { variant: 'h6' }, + subheader: { variant: 'caption' } + }} /> {isFetching ? ( @@ -69,7 +63,13 @@ const LeaderboardCard = ({ title, subheader, rows, isFetching }) => ( ))} ) : rows.length === 0 ? ( - + No scored tenants yet ) : ( @@ -80,22 +80,24 @@ const LeaderboardCard = ({ title, subheader, rows, isFetching }) => ( component={Link} href={`/tenant/administration/securescore?tenantFilter=${row.tenant}`} direction="row" - alignItems="center" spacing={1.5} sx={{ + alignItems: "center", px: 1, py: 0.75, borderRadius: 1, textDecoration: 'none', color: 'inherit', - '&:hover': { backgroundColor: 'action.hover' }, - }} - > + '&:hover': { backgroundColor: 'action.hover' } + }}> + sx={{ + color: "text.disabled", + width: 24, + flexShrink: 0, + fontVariantNumeric: 'tabular-nums' + }}> #{row.rank} @@ -157,20 +159,20 @@ export const AllTenantsSecureScoreSummary = () => { { name: 'Portfolio average', data: `${summary.average}%`, - icon: , + icon: , toolTip: `Average of each tenant's latest cached score, across ${summary.scored} tenants`, }, { name: summary.trend.length > 1 ? `Change over ${summary.trend.length} days` : 'Change', data: deltaLabel, - icon: summary.delta < 0 ? : , + icon: summary.delta < 0 ? : , color: summary.delta < 0 ? 'error' : summary.delta > 0 ? 'success' : 'primary', toolTip: 'Movement of the portfolio average across the retained history window', }, { name: 'Highest', data: summary.best ? `${summary.best.percent}%` : '—', - icon: , + icon: , color: 'success', toolTip: summary.best ? `${summary.best.name} — ${summary.best.current} of ${summary.best.max} points` @@ -179,7 +181,7 @@ export const AllTenantsSecureScoreSummary = () => { { name: 'Lowest', data: summary.worst ? `${summary.worst.percent}%` : '—', - icon: , + icon: , color: 'error', toolTip: summary.worst ? `${summary.worst.name} — ${summary.worst.current} of ${summary.worst.max} points` @@ -208,9 +210,10 @@ export const AllTenantsSecureScoreSummary = () => { + slotProps={{ + title: { variant: 'h6' }, + subheader: { variant: 'caption' } + }} /> { - ) + ); } export const AllTenantsSecureScoreTable = () => { @@ -252,12 +255,18 @@ export const AllTenantsSecureScoreTable = () => { return ( - + No cached secure scores yet. Scores appear after the nightly cache job has run. - ) + ); } return ( @@ -279,7 +288,7 @@ export const AllTenantsSecureScoreTable = () => { { label: 'View tenant secure score', link: '/tenant/administration/securescore?tenantFilter=[Tenant]', - icon: , + icon: , }, ]} /> diff --git a/src/components/CippAllTenants/AllTenantsTestResults.jsx b/src/components/CippAllTenants/AllTenantsTestResults.jsx index 3c0b517ca832..bc5f9d50710d 100644 --- a/src/components/CippAllTenants/AllTenantsTestResults.jsx +++ b/src/components/CippAllTenants/AllTenantsTestResults.jsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Button, Card, @@ -8,7 +9,6 @@ import { Typography, } from '@mui/material' import { Grid } from '@mui/system' -import { EyeIcon } from '@heroicons/react/24/outline' import { ApiGetCall } from '../../api/ApiCall.jsx' import { getCippError } from '../../utils/get-cipp-error' import { CippDataTable } from '../CippTable/CippDataTable' @@ -247,7 +247,7 @@ export const AllTenantsTestResults = ({ testType, title, perTenantPath }) => { { label: 'View tenant dashboard', link: `${perTenantPath}?tenantFilter=[Tenant]`, - icon: , + icon: , }, ] : [] @@ -257,3 +257,4 @@ export const AllTenantsTestResults = ({ testType, title, perTenantPath }) => { ) } + diff --git a/src/components/CippBaselines/CippBaselineStandardDialog.jsx b/src/components/CippBaselines/CippBaselineStandardDialog.jsx index 3727149ea2b2..d517a918e303 100644 --- a/src/components/CippBaselines/CippBaselineStandardDialog.jsx +++ b/src/components/CippBaselines/CippBaselineStandardDialog.jsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { differenceInDays } from 'date-fns' import { Box, @@ -23,14 +24,6 @@ import { Typography, } from '@mui/material' import { Grid } from '@mui/system' -import { - Add, - Check, - Clear, - Search, - ViewList, - ViewModule, -} from '@mui/icons-material' import { CippAutoComplete } from '../CippComponents/CippAutocomplete' const impactColors = { @@ -83,10 +76,11 @@ const StandardTagChips = ({ tags, max = 4 }) => { + sx={{ + flexWrap: "wrap", + mt: 1 + }}> {visible.map((tag) => ( { /> )} - ) + ); } // Browse-and-add picker for Baseline standard definitions. The filters stay @@ -167,6 +161,10 @@ export const CippBaselineStandardDialog = ({ .length const filtered = catalog.filter((standard) => { + // A disabled definition is not ready for use: it never appears in the catalog, but + // baselines that already contain it still render in the editor (catalogByName keeps + // the full list) so existing configuration never turns invisible. + if (standard.disabled === true) return false const query = search.toLowerCase() const matchesSearch = !query || @@ -272,7 +270,7 @@ export const CippBaselineStandardDialog = ({ fullWidth size="small" variant={isSelected ? 'contained' : 'outlined'} - startIcon={isSelected && !standard.multiple ? : } + startIcon={isSelected && !standard.multiple ? : } onClick={() => onToggle(standard.name)} > {standard.multiple @@ -300,10 +298,12 @@ export const CippBaselineStandardDialog = ({ onChange={(event) => setSearch(event.target.value)} autoComplete="off" placeholder="Search by name, description, or benchmark tag..." - InputProps={{ - startAdornment: ( - - ), + slotProps={{ + input: { + startAdornment: ( + + ), + } }} /> @@ -383,20 +383,25 @@ export const CippBaselineStandardDialog = ({ - - + sx={{ + alignItems: "center", + justifyContent: "space-between", + flexWrap: "wrap" + }}> + + Showing {sorted.length} of {catalog.length} standards {hasActiveFilters && ( - ) + ); } export default CippBaselineStandardDialog diff --git a/src/components/CippBaselines/CippBaselineStandardItem.jsx b/src/components/CippBaselines/CippBaselineStandardItem.jsx index 179f03ea4fec..66520be1f079 100644 --- a/src/components/CippBaselines/CippBaselineStandardItem.jsx +++ b/src/components/CippBaselines/CippBaselineStandardItem.jsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Accordion, AccordionDetails, @@ -13,7 +14,6 @@ import { Typography, } from '@mui/material' import { Grid } from '@mui/system' -import { AutoFixHigh, Delete, ExpandMore } from '@mui/icons-material' import { useWatch } from 'react-hook-form' import CippFormComponent from '../CippComponents/CippFormComponent' import CippBaselineStandardSettings from './CippBaselineStandardSettings' @@ -45,7 +45,7 @@ export const renderExpectedValue = (template, values) => { return template.replace(/%(\w+)%/g, (match, token) => { const value = resolveToken(token, values) return value === undefined ? match : value - }) + }); } if (Array.isArray(template)) { return template.map((item) => renderExpectedValue(item, values)) @@ -160,21 +160,22 @@ export const CippBaselineStandardItem = ({ expanded={expanded} onChange={onToggle} disableGutters - // Collapsed details stay UNMOUNTED: with hundreds of standards loaded, mounting - // every settings form (and its api-driven autocompletes) at once makes the - // editor crawl. react-hook-form keeps the values when details unmount, and the - // stage serializer falls back to the saved config for never-expanded standards. - TransitionProps={{ unmountOnExit: true }} sx={{ '&:before': { display: 'none' }, borderRadius: '12px' }} + slotProps={{ + transition: { unmountOnExit: true } + }} > - }> + }> + sx={{ + alignItems: "center", + justifyContent: "space-between", + width: '100%', + pr: 1, + minWidth: 0 + }}> {identityLabel @@ -182,19 +183,22 @@ export const CippBaselineStandardItem = ({ : standard.label} {instanceNumber ? ` (instance ${instanceNumber})` : ''} - + {standard.cat} + sx={{ + alignItems: "center", + flexWrap: "wrap", + justifyContent: "flex-end", + minWidth: 0 + }}> - + @@ -264,10 +268,14 @@ export const CippBaselineStandardItem = ({ - + {standard.helpText} - + {(standard.recommendedBy ?? []).map((source) => ( + sx={{ + alignItems: "center", + justifyContent: "space-between" + }}> Settings @@ -310,7 +319,7 @@ export const CippBaselineStandardItem = ({ @@ -556,7 +549,7 @@ export const CippDomainCards = ({ domain: propDomain = "", fullwidth = false }) - ) + ); } diff --git a/src/components/CippCards/CippUniversalSearch.jsx b/src/components/CippCards/CippUniversalSearch.jsx index 589ac2c117bf..e5d446349e18 100644 --- a/src/components/CippCards/CippUniversalSearch.jsx +++ b/src/components/CippCards/CippUniversalSearch.jsx @@ -46,7 +46,12 @@ export const CippUniversalSearch = React.forwardRef( /> {search.isFetching && ( - + )} @@ -87,17 +92,26 @@ const Results = ({ items = [], searchValue }) => { return ( <> - + {totalResults} results (Page {currentPage} of {totalPages}) - + {displayedResults.map((item, key) => ( ))} - + )} @@ -732,7 +741,14 @@ export const CippUniversalSearchV2 = React.forwardRef( {/* One tap to any scope — the desktop dropdown cost two, and the recorded mobile gap was that entity search had no direct entry point at all. */} {isMobile && ( - + {typeMenuActions.map((action) => { const active = action.label === searchType; return ( @@ -749,7 +765,14 @@ export const CippUniversalSearchV2 = React.forwardRef( )} {isMobile && searchType === "BitLocker" && ( - + {bitlockerLookupActions.map((action) => { const active = (action.label === "Device ID") === (bitlockerLookupType === "deviceId"); @@ -801,12 +824,14 @@ export const CippUniversalSearchV2 = React.forwardRef( }} > - + ))} @@ -875,7 +900,9 @@ const Results = ({ const parts = text?.split(new RegExp(`(${escapedSearch})`, "gi")); return parts?.map((part, index) => part.toLowerCase() === searchValue.toLowerCase() ? ( - + {part} ) : ( @@ -917,7 +944,9 @@ const Results = ({ - + {highlightMatch(itemData.displayName || itemData.skuPartNumber || "Unknown SKU")} {itemData.skuPartNumber && ( @@ -940,7 +969,13 @@ const Results = ({ {highlightMatch(itemData.skuId)} )} - + {itemData.tenantCount || 0} tenant{itemData.tenantCount === 1 ? "" : "s"} {" · "} {itemData.totalAssigned || 0}/{itemData.totalAvailable || 0} assigned @@ -949,16 +984,15 @@ const Results = ({ {planNames && ( + WebkitBoxOrient: "vertical" + }}> {highlightMatch(planNames)} )} @@ -989,26 +1023,37 @@ const Results = ({ > + {highlightMatch(itemData.displayName || "")} } secondary={ {searchType === "Users" && ( - + {highlightMatch(itemData.userPrincipalName || "")} )} {searchType === "Groups" && ( <> {itemData.mail && ( - + {highlightMatch(itemData.mail || "")} )} {itemData.description && ( - + {highlightMatch(itemData.description || "")} )} @@ -1017,12 +1062,19 @@ const Results = ({ {searchType === "Applications" && ( <> {itemData.appId && ( - + {highlightMatch(itemData.appId || "")} )} {itemData.publisherName && ( - + {highlightMatch(itemData.publisherName || "")} )} @@ -1030,9 +1082,11 @@ const Results = ({ )} + sx={{ + color: "text.secondary", + display: "block", + mt: 0.5 + }}> Tenant: {tenantDomain} @@ -1058,7 +1112,9 @@ const PageResults = ({ const parts = text.split(new RegExp(`(${escapedSearch})`, "gi")); return parts.map((part, index) => part.toLowerCase() === searchValue.toLowerCase() ? ( - + {part} ) : ( @@ -1095,13 +1151,19 @@ const PageResults = ({ - + {highlightMatch(item.title || "")} - + {itemType} - + {isGlobal ? "Global" : "Tenant"} @@ -1109,7 +1171,9 @@ const PageResults = ({ secondary={ {item.breadcrumbs && item.breadcrumbs.length > 0 && ( - + {item.breadcrumbs.map((crumb, idx) => ( {highlightMatch(crumb)} @@ -1120,7 +1184,13 @@ const PageResults = ({ {highlightMatch(item.title || "")} )} - + Path: {highlightMatch(item.path || "")} @@ -1161,19 +1231,31 @@ const BitlockerResults = ({ > + {result.deviceName || "Unknown Device"} } secondary={ - + Key ID: {result.keyId || "N/A"} - + Device ID: {result.deviceId || "N/A"} - + Tenant: {result.tenant || "N/A"} diff --git a/src/components/CippCards/CippUserInfoCard.jsx b/src/components/CippCards/CippUserInfoCard.jsx index 54cf5f42aadd..fceb4c90d19c 100644 --- a/src/components/CippCards/CippUserInfoCard.jsx +++ b/src/components/CippCards/CippUserInfoCard.jsx @@ -1,4 +1,5 @@ import PropTypes from "prop-types"; +import { CippIcons } from "../../utils/icon-registry"; import { Avatar, Card, @@ -11,7 +12,6 @@ import { Tooltip, CircularProgress, } from "@mui/material"; -import { AccountCircle, PhotoCamera, Delete } from "@mui/icons-material"; import { PropertyList } from "../property-list"; import { PropertyListItem } from "../property-list-item"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; @@ -136,13 +136,19 @@ export const CippUserInfoCard = (props) => { isFetching ? ( ) : ( - + {/* Avatar section — "auto" rather than a full row on xs: the picture is a fixed 64px, so collapsing it to its own line only pushes the identity fields down without making the picture any bigger. */} - - + + { variant="circular" src={imageUrl} > - + {isLoading && ( { "&:hover": { backgroundColor: "action.selected" }, }} > - +
@@ -200,18 +206,25 @@ export const CippUserInfoCard = (props) => { }, }} > - + {/* Status messages */} {successMessage && ( - + {successMessage} )} {uploadError && ( - + {uploadError} )} @@ -222,7 +235,9 @@ export const CippUserInfoCard = (props) => { - + Account Enabled: @@ -231,7 +246,9 @@ export const CippUserInfoCard = (props) => { - + Synced from AD: @@ -254,7 +271,9 @@ export const CippUserInfoCard = (props) => { ) : ( - + Display Name: @@ -262,7 +281,9 @@ export const CippUserInfoCard = (props) => { - + Email Address: @@ -270,7 +291,9 @@ export const CippUserInfoCard = (props) => { - + User Principal Name: @@ -308,14 +331,21 @@ export const CippUserInfoCard = (props) => { isFetching ? ( ) : !hasWorkInfo ? ( - + No work information available ) : ( {user?.jobTitle && ( - + Job Title: {user.jobTitle} @@ -323,7 +353,9 @@ export const CippUserInfoCard = (props) => { )} {user?.companyName && ( - + Company Name: {user.companyName} @@ -331,7 +363,9 @@ export const CippUserInfoCard = (props) => { )} {user?.department && ( - + Department: {user.department} @@ -339,7 +373,9 @@ export const CippUserInfoCard = (props) => { )} {user?.manager?.displayName && ( - + Manager: {user.manager.displayName} @@ -358,14 +394,21 @@ export const CippUserInfoCard = (props) => { isFetching ? ( ) : !hasContactInfo ? ( - + No contact information available ) : ( {user?.mobilePhone && ( - + Mobile Phone: {user.mobilePhone} @@ -373,7 +416,9 @@ export const CippUserInfoCard = (props) => { )} {user?.businessPhones && user.businessPhones.length > 0 && ( - + Business Phones: {user.businessPhones.join(", ")} @@ -391,14 +436,21 @@ export const CippUserInfoCard = (props) => { isFetching ? ( ) : !hasAddressInfo ? ( - + No address information available ) : ( {user?.streetAddress && ( - + Street Address: {user.streetAddress} @@ -406,7 +458,9 @@ export const CippUserInfoCard = (props) => { )} {user?.city && ( - + City: {user.city} @@ -414,7 +468,9 @@ export const CippUserInfoCard = (props) => { )} {user?.postalCode && ( - + Postal Code: {user.postalCode} @@ -422,7 +478,9 @@ export const CippUserInfoCard = (props) => { )} {user?.country && ( - + Country: {user.country} @@ -430,7 +488,9 @@ export const CippUserInfoCard = (props) => { )} {user?.officeLocation && ( - + Office Location: {user.officeLocation} diff --git a/src/components/CippComponents/AlertsOverviewCard.jsx b/src/components/CippComponents/AlertsOverviewCard.jsx index f639d93d7678..4b11d896f046 100644 --- a/src/components/CippComponents/AlertsOverviewCard.jsx +++ b/src/components/CippComponents/AlertsOverviewCard.jsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Box, Button, @@ -13,12 +14,6 @@ import { Tooltip, Typography, } from '@mui/material' -import { - DeleteOutline as DeleteIcon, - NotificationsActive as AlertIcon, - Settings as SettingsIcon, - Snooze as SnoozeIcon, -} from '@mui/icons-material' import Link from 'next/link' import { ApiGetCall } from '../../api/ApiCall' import { getCippError } from '../../utils/get-cipp-error' @@ -55,7 +50,7 @@ const describeSnooze = (snooze) => { const SnoozeStatusChip = ({ snooze }) => { if (snooze.Status === 'Forever') { - return } label="Forever" /> + return } label="Forever" /> } if (snooze.Status === 'Expired') { return @@ -64,7 +59,7 @@ const SnoozeStatusChip = ({ snooze }) => { } + icon={} label={`${snooze.RemainingDays}d`} /> ) @@ -160,14 +155,14 @@ export const AlertsOverviewCard = ({ tenantFilter, sx }) => { size="small" color={activeItems.length ? 'error' : 'success'} variant={activeItems.length ? 'filled' : 'outlined'} - icon={} + icon={} label={`${activeItems.length} Active`} /> } + icon={} label={`${activeSnoozeCount} Snoozed`} /> @@ -181,32 +176,45 @@ export const AlertsOverviewCard = ({ tenantFilter, sx }) => { const secondary = detail ? `${label} · ${detail}` : label return ( - - + {/* flex: 1 + minWidth: 0 lets the ellipsis engage before the row runs under the icon */} + + {title} - + {secondary} - setSnoozeTarget(item)}> - + setSnoozeTarget(item)} sx={{ flexShrink: 0 }}> + - ) + ); })} ) : ( - + No active alerts for this tenant. )} {sortedSnoozes.length > 0 && ( - + Snoozed }> @@ -220,15 +228,18 @@ export const AlertsOverviewCard = ({ tenantFilter, sx }) => { key={`snoozed-${snooze.PartitionKey}-${snooze.RowKey}`} sx={{ ...rowSx, opacity: 0.55 }} > - + {/* flex: 1 + minWidth: 0 lets the ellipsis engage before the row runs under the icons */} + {title} {secondary} @@ -236,25 +247,26 @@ export const AlertsOverviewCard = ({ tenantFilter, sx }) => { + sx={{ + alignItems: "center", + flexShrink: 0 + }}> removeDialog.handleOpen(snooze)}> - + - ) + ); })} )} - ) + ); } return ( @@ -262,7 +274,7 @@ export const AlertsOverviewCard = ({ tenantFilter, sx }) => { - + Alerts } @@ -271,7 +283,7 @@ export const AlertsOverviewCard = ({ tenantFilter, sx }) => { component={Link} href="/tenant/administration/alert-configuration" size="small" - startIcon={} + startIcon={} > Manage diff --git a/src/components/CippComponents/AppApprovalTemplateForm.jsx b/src/components/CippComponents/AppApprovalTemplateForm.jsx index 10948b97f1d7..bed642cebbc7 100644 --- a/src/components/CippComponents/AppApprovalTemplateForm.jsx +++ b/src/components/CippComponents/AppApprovalTemplateForm.jsx @@ -510,7 +510,7 @@ const AppApprovalTemplateForm = ({ name="appType" label="Application Type" type="select" - clearable={false} + disableClearable={true} options={[ { label: "Enterprise Application", value: "EnterpriseApp" }, { label: "Gallery Template", value: "GalleryTemplate" }, diff --git a/src/components/CippComponents/AppRegistrationActions.jsx b/src/components/CippComponents/AppRegistrationActions.jsx index c2869f3c7eb2..165c1bbebee3 100644 --- a/src/components/CippComponents/AppRegistrationActions.jsx +++ b/src/components/CippComponents/AppRegistrationActions.jsx @@ -1,4 +1,5 @@ -import { Launch, Delete, Key, Security, ContentCopy, Visibility, Edit } from '@mui/icons-material' + +import { CippIcons } from '../../utils/icon-registry' import isEqual from 'lodash/isEqual' import { CippFormComponent } from './CippFormComponent.jsx' import { CertificateCredentialRemovalForm } from './CertificateCredentialRemovalForm.jsx' @@ -9,9 +10,10 @@ const entraLinkActions = (forHeaderMenu) => { const extra = forHeaderMenu ? headerLinkProps : {} return [ { - icon: , + icon: , label: 'View App Registration', link: `https://entra.microsoft.com/[Tenant]/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/[appId]/isMSAApp/`, + pinned: true, color: 'info', target: '_blank', multiPost: false, @@ -19,9 +21,10 @@ const entraLinkActions = (forHeaderMenu) => { ...extra, }, { - icon: , + icon: , label: 'View API Permissions', link: `https://entra.microsoft.com/[Tenant]/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/[appId]/isMSAApp/`, + pinned: true, color: 'info', target: '_blank', multiPost: false, @@ -32,9 +35,10 @@ const entraLinkActions = (forHeaderMenu) => { } const editInEntraAction = { - icon: , + icon: , label: 'Edit App Registration', link: `https://entra.microsoft.com/[Tenant]/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/AppRegSettings/appId/[appId]/isMSAApp/`, + pinned: true, color: 'success', target: '_blank', multiPost: false, @@ -77,7 +81,7 @@ export const ADD_CLIENT_SECRET_FIELDS = [ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) => [ { - icon: , + icon: , label: 'Create Enterprise App Template (Multi-Tenant)', type: 'POST', color: 'info', @@ -104,7 +108,7 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) row?.signInAudience === 'AzureADandPersonalMicrosoftAccount'), }, { - icon: , + icon: , label: 'Create Manifest Template (Single-Tenant)', type: 'POST', color: 'success', @@ -157,7 +161,7 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) canWriteApplication && row.signInAudience === 'AzureADMyOrg' && !row?.applicationTemplateId, }, { - icon: , + icon: , label: 'Add Client Secret', type: 'POST', color: 'success', @@ -176,7 +180,7 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) condition: () => canWriteApplication, }, { - icon: , + icon: , label: 'Remove Password Credentials', type: 'POST', color: 'warning', @@ -212,7 +216,7 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) condition: (row) => canWriteApplication && row?.passwordCredentials?.length > 0, }, { - icon: , + icon: , label: 'Remove Certificate Credentials', type: 'POST', color: 'warning', @@ -230,7 +234,7 @@ export const getAppRegistrationPostAndDestructiveActions = (canWriteApplication) condition: (row) => canWriteApplication && row?.keyCredentials?.length > 0, }, { - icon: , + icon: , label: 'Delete App Registration', type: 'POST', color: 'error', @@ -268,7 +272,7 @@ const redirectUrisFromForm = (value) => // must include tenantFilter from the detail page's actionsData.Tenant. export const getAppRegistrationEditActions = (canWriteApplication) => [ { - icon: , + icon: , label: 'Edit Authentication', type: 'POST', color: 'info', @@ -362,9 +366,10 @@ export const getAppRegistrationEditActions = (canWriteApplication) => [ export const getAppRegistrationListActions = (canWriteApplication) => [ { - icon: , + icon: , label: 'View in CIPP', link: '/tenant/administration/applications/app-registration?appId=[appId]&tenantFilter=[Tenant]', + pinned: true, color: 'info', multiPost: false, external: false, diff --git a/src/components/CippComponents/AssessmentCard.jsx b/src/components/CippComponents/AssessmentCard.jsx index 0fdd78f83a9f..805808b3bf09 100644 --- a/src/components/CippComponents/AssessmentCard.jsx +++ b/src/components/CippComponents/AssessmentCard.jsx @@ -7,10 +7,29 @@ import { Skeleton, Tooltip, } from "@mui/material"; -import { Security as SecurityIcon } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; +import { useRouter } from "next/router"; import { CippTimeAgo } from "../CippComponents/CippTimeAgo"; +// Each assessment category lines up with a dashboard tab, so its row links straight to the +// matching result list. Keys match the `key` values in testCategories below. +const categoryTabPaths = { + identity: "/dashboardv2/identity", + devices: "/dashboardv2/devices", + custom: "/dashboardv2/custom", +}; + export const AssessmentCard = ({ data, isLoading, title, description }) => { + const router = useRouter(); + + // Jump to the category's tab, keeping the currently selected test suite (reportId) so the + // destination list stays in sync with what the card is summarising. + const navigateToCategory = (key) => { + const path = categoryTabPaths[key]; + if (!path) return; + const reportId = router.query?.reportId; + router.push(reportId ? { pathname: path, query: { reportId } } : path); + }; // Extract data with null safety const identityPassed = data?.TestResultSummary?.IdentityPassed || 0; const identityFailed = data?.TestResultSummary?.IdentityFailed || 0; @@ -145,7 +164,7 @@ export const AssessmentCard = ({ data, isLoading, title, description }) => { - + {title || "Assessment"} } @@ -180,21 +199,26 @@ export const AssessmentCard = ({ data, isLoading, title, description }) => { ) : ( - + Description + lineHeight: 1.35 + }}> {descriptionText} @@ -249,8 +273,38 @@ export const AssessmentCard = ({ data, isLoading, title, description }) => { ].filter((item) => item.value > 0); return ( - - + navigateToCategory(category.key)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + navigateToCategory(category.key); + } + }} + role="button" + tabIndex={0} + aria-label={`View ${category.label} test results`} + sx={{ + cursor: "pointer", + borderRadius: 1, + mx: -0.5, + px: 0.5, + transition: "background-color 0.15s ease-in-out", + "&:hover": { bgcolor: "action.hover" }, + "&:hover .assessment-category-label": { + color: "primary.main", + textDecoration: "underline", + }, + }} + > + {category.label + ` (${category.total})`} { + lineHeight: 1.25 + }}> {statusItems.length > 0 ? ( statusItems.map((item, index) => ( @@ -298,7 +351,9 @@ export const AssessmentCard = ({ data, isLoading, title, description }) => { ) : ( - + No assessment tests available @@ -316,10 +371,14 @@ export const AssessmentCard = ({ data, isLoading, title, description }) => { }} > - + Last Data Collection - + {isLoading ? ( ) : data?.ExecutedAt ? ( @@ -336,7 +395,9 @@ export const AssessmentCard = ({ data, isLoading, title, description }) => { ) : overallCategory.show && ( - + {overallCategory.label + ` (${overallCategory.total})`} { )) ) : ( - + No results )} diff --git a/src/components/CippComponents/AuthMethodCard.jsx b/src/components/CippComponents/AuthMethodCard.jsx index b8b642552bab..02eea87ff413 100644 --- a/src/components/CippComponents/AuthMethodCard.jsx +++ b/src/components/CippComponents/AuthMethodCard.jsx @@ -1,5 +1,5 @@ import { Box, Card, CardHeader, CardContent, Typography, Skeleton } from "@mui/material"; -import { People as UsersIcon } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; import { CippSankey } from "./CippSankey"; import { useRouter } from "next/router"; @@ -44,7 +44,7 @@ export const AuthMethodCard = ({ data, isLoading }) => { let whfbCount = 0; enabledUsers.forEach((user) => { - const methods = Array.isArray(user.MFAMethods) ? user.MFAMethods : []; + const methods = Array.isArray(user.MFAMethods) ? user.MFAMethods : user.MFAMethods ? [user.MFAMethods] : []; const perUser = user.PerUser === "enforced" || user.PerUser === "enabled"; const hasRegistered = user.MFARegistration === true; @@ -228,7 +228,7 @@ export const AuthMethodCard = ({ data, isLoading }) => { "&:hover": { textDecoration: "underline" }, }} > - + All users auth methods } @@ -254,7 +254,9 @@ export const AuthMethodCard = ({ data, isLoading }) => { width: "100%", }} > - + No authentication method data available @@ -263,7 +265,9 @@ export const AuthMethodCard = ({ data, isLoading }) => { {!isLoading && processedData?.description && ( - + {processedData.description} diff --git a/src/components/CippComponents/AuthMethodSankey.jsx b/src/components/CippComponents/AuthMethodSankey.jsx index f65ec13c1483..3bbffe564348 100644 --- a/src/components/CippComponents/AuthMethodSankey.jsx +++ b/src/components/CippComponents/AuthMethodSankey.jsx @@ -45,7 +45,7 @@ export const AuthMethodSankey = ({ data }) => { let whfbCount = 0; enabledUsers.forEach((user) => { - const methods = user.MFAMethods || []; + const methods = Array.isArray(user.MFAMethods) ? user.MFAMethods : user.MFAMethods ? [user.MFAMethods] : []; const perUser = user.PerUser === "enforced" || user.PerUser === "enabled"; const hasRegistered = user.MFARegistration === true; diff --git a/src/components/CippComponents/BPASyncDialog.jsx b/src/components/CippComponents/BPASyncDialog.jsx index 3add79b46f54..c994f0ea8ccd 100644 --- a/src/components/CippComponents/BPASyncDialog.jsx +++ b/src/components/CippComponents/BPASyncDialog.jsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Dialog, DialogContent, @@ -6,7 +7,6 @@ import { Button, DialogActions, } from "@mui/material"; -import { Sync } from "@mui/icons-material"; import { useForm, FormProvider } from "react-hook-form"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; import { ApiPostCall } from "../../api/ApiCall"; @@ -72,7 +72,7 @@ export const BPASyncDialog = ({ createDialog }) => { type="submit" variant="contained" disabled={isSyncing && bpaSyncResults.isLoading} - startIcon={} + startIcon={} > Sync BPA diff --git a/src/components/CippComponents/BreachSearchDialog.jsx b/src/components/CippComponents/BreachSearchDialog.jsx index 8a69cd40206c..c8f414b5b22c 100644 --- a/src/components/CippComponents/BreachSearchDialog.jsx +++ b/src/components/CippComponents/BreachSearchDialog.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Dialog, DialogContent, DialogTitle, Button, DialogActions } from "@mui/material"; -import { Search } from "@mui/icons-material"; import { useForm, FormProvider } from "react-hook-form"; import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "./CippApiResults"; @@ -55,7 +55,7 @@ export const BreachSearchDialog = ({ createDialog }) => { type="submit" variant="contained" disabled={isRunning || breachSearchResults.isLoading} - startIcon={} + startIcon={} > Run Breach Search diff --git a/src/components/CippComponents/CIPPM365OAuthButton.jsx b/src/components/CippComponents/CIPPM365OAuthButton.jsx index 504aa2404279..2ae25c9675bb 100644 --- a/src/components/CippComponents/CIPPM365OAuthButton.jsx +++ b/src/components/CippComponents/CIPPM365OAuthButton.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Alert, Button, Typography, CircularProgress, Box } from '@mui/material' -import { Microsoft, Login, Refresh } from '@mui/icons-material' import { ApiGetCall } from '../../api/ApiCall' import { CippCopyToClipBoard } from './CippCopyToClipboard' import { CippApiDialog } from './CippApiDialog' @@ -311,12 +311,11 @@ export const CIPPM365OAuthButton = ({ tenantId = idTokenPayload.tid } - if (username && username.includes('@') && username.includes('.onmicrosoft.com')) { - onmicrosoftDomain = username.split('@')[1] - } else if (idTokenPayload.iss) { - const issuerMatch = idTokenPayload.iss.match(/https:\/\/sts\.windows\.net\/([^/]+)\//) - if (issuerMatch && issuerMatch[1]) { - } + // Only accept the domain part when it actually ends in .onmicrosoft.com; a plain + // substring check would also match user@evil.onmicrosoft.com.example. + const domainPart = username && username.includes('@') ? username.split('@')[1] : '' + if (domainPart && domainPart.toLowerCase().endsWith('.onmicrosoft.com')) { + onmicrosoftDomain = domainPart } setIsServiceAccount(checkIsServiceAccount(username)) } catch (error) {} @@ -743,7 +742,9 @@ export const CIPPM365OAuthButton = ({ )} {showResults && ( - + {deviceCodeInfo ? ( Application Creation @@ -783,7 +784,9 @@ export const CIPPM365OAuthButton = ({ )} - + Code expires in {Math.round(deviceCodeInfo.expires_in / 60)} minutes @@ -805,7 +808,9 @@ export const CIPPM365OAuthButton = ({ )} - + Refresh token expires: {tokens.refreshTokenExpiresOn?.toLocaleString()} @@ -831,10 +836,14 @@ export const CIPPM365OAuthButton = ({ Authentication Error: {authError.errorCode} {authError.errorMessage} - + Time: {authError.timestamp} - + @@ -897,13 +906,13 @@ export const CIPPM365OAuthButton = ({ color="primary" startIcon={ canRestartDeviceLogin ? ( - + ) : authInProgress || codeRetrievalInProgress ? ( ) : tokens.accessToken ? ( - + ) : ( - + ) } > @@ -916,5 +925,5 @@ export const CIPPM365OAuthButton = ({ : buttonText} - ) + ); } diff --git a/src/components/CippComponents/CippAddConnectorDrawer.jsx b/src/components/CippComponents/CippAddConnectorDrawer.jsx index e08654239b14..764961f0d300 100644 --- a/src/components/CippComponents/CippAddConnectorDrawer.jsx +++ b/src/components/CippComponents/CippAddConnectorDrawer.jsx @@ -1,8 +1,8 @@ import { useState, useEffect } from "react"; import { Button, Divider } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { Grid } from "@mui/system"; import { useForm, useFormState, useWatch } from "react-hook-form"; -import { RocketLaunch } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; @@ -78,9 +78,9 @@ export const CippAddConnectorDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAddContactDrawer.jsx b/src/components/CippComponents/CippAddContactDrawer.jsx index f46b5b54f80e..0a3604749335 100644 --- a/src/components/CippComponents/CippAddContactDrawer.jsx +++ b/src/components/CippComponents/CippAddContactDrawer.jsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; import { Button, Divider } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry" import { Grid } from "@mui/system"; import { useForm, useFormState } from "react-hook-form"; -import { PersonAdd } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippApiResults } from "./CippApiResults"; @@ -130,9 +130,9 @@ export const CippAddContactDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAddDomainDrawer.jsx b/src/components/CippComponents/CippAddDomainDrawer.jsx index f5017f3fa379..e51d0ca7768e 100644 --- a/src/components/CippComponents/CippAddDomainDrawer.jsx +++ b/src/components/CippComponents/CippAddDomainDrawer.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Button, Box, Alert } from "@mui/material"; import { useForm, useFormState } from "react-hook-form"; -import { AddCircleOutline } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; @@ -66,9 +66,9 @@ export const CippAddDomainDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAddEquipmentDrawer.jsx b/src/components/CippComponents/CippAddEquipmentDrawer.jsx index 3d9a397765c6..c187764fafc3 100644 --- a/src/components/CippComponents/CippAddEquipmentDrawer.jsx +++ b/src/components/CippComponents/CippAddEquipmentDrawer.jsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; import { Button, Divider } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { Grid } from "@mui/system"; import { useForm, useFormState } from "react-hook-form"; -import { AddBusiness } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormDomainSelector } from "./CippFormDomainSelector"; @@ -79,9 +79,9 @@ export const CippAddEquipmentDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAddRoomDrawer.jsx b/src/components/CippComponents/CippAddRoomDrawer.jsx index 8dc5060908aa..5d4139e36ba8 100644 --- a/src/components/CippComponents/CippAddRoomDrawer.jsx +++ b/src/components/CippComponents/CippAddRoomDrawer.jsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; import { Button, Divider } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { Grid } from "@mui/system"; import { useForm, useFormState } from "react-hook-form"; -import { AddHomeWork } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormDomainSelector } from "./CippFormDomainSelector"; @@ -87,9 +87,9 @@ export const CippAddRoomDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAddRoomListDrawer.jsx b/src/components/CippComponents/CippAddRoomListDrawer.jsx index 6ced8947993b..7a3b408634ad 100644 --- a/src/components/CippComponents/CippAddRoomListDrawer.jsx +++ b/src/components/CippComponents/CippAddRoomListDrawer.jsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; import { Button, InputAdornment, Divider } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { Grid } from "@mui/system"; import { useForm, useFormState } from "react-hook-form"; -import { ListAlt } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormDomainSelector } from "./CippFormDomainSelector"; @@ -80,9 +80,9 @@ export const CippAddRoomListDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} @@ -136,9 +136,7 @@ export const CippAddRoomListDrawer = ({ "Username can only contain letters, numbers, hyphens, underscores, and periods", }, }} - InputProps={{ - endAdornment: @, - }} + slotProps={{ input: { endAdornment: @ } }} /> diff --git a/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx b/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx index c73964281119..8a6be0f3da7e 100644 --- a/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx +++ b/src/components/CippComponents/CippAddTenantAllowBlockListDrawer.jsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Button, Divider } from "@mui/material"; import { Grid } from "@mui/system"; import { useForm, useFormState, useWatch } from "react-hook-form"; -import { PlaylistAdd } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; @@ -210,9 +210,9 @@ export const CippAddTenantAllowBlockListDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAddTenantGroupDrawer.jsx b/src/components/CippComponents/CippAddTenantGroupDrawer.jsx index a82e0fd3f791..d98fa3717dd2 100644 --- a/src/components/CippComponents/CippAddTenantGroupDrawer.jsx +++ b/src/components/CippComponents/CippAddTenantGroupDrawer.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Button, Box } from "@mui/material"; import { useForm, useFormState } from "react-hook-form"; -import { GroupAdd } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import { CippApiResults } from "./CippApiResults"; import { ApiPostCall } from "../../api/ApiCall"; @@ -74,9 +74,9 @@ export const CippAddTenantGroupDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAddTestReportDrawer.jsx b/src/components/CippComponents/CippAddTestReportDrawer.jsx index 602415a3ea57..915f5bde2975 100644 --- a/src/components/CippComponents/CippAddTestReportDrawer.jsx +++ b/src/components/CippComponents/CippAddTestReportDrawer.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Button, Card, @@ -14,7 +15,6 @@ import { } from '@mui/material' import { Grid } from '@mui/system' import { useForm, useFormState, useWatch } from 'react-hook-form' -import { Add, Edit } from '@mui/icons-material' import { CippOffCanvas } from './CippOffCanvas' import CippFormComponent from './CippFormComponent' import { CippApiResults } from './CippApiResults' @@ -205,7 +205,7 @@ export const CippAddTestReportDrawer = ({ px: 2, }} onClick={() => setDrawerVisible(true)} - startIcon={isEditMode ? : } + startIcon={isEditMode ? : } disabled={disabled} > - + Selected Tests: @@ -319,7 +321,9 @@ export const CippAddTestReportDrawer = ({ variant="outlined" /> - + Total:{' '} {selectedIdentityTests.length + selectedDeviceTests.length + @@ -406,11 +410,15 @@ export const CippAddTestReportDrawer = ({ > {availableTestsApi.isFetching ? ( - Loading tests... + Loading tests... ) : currentTests.length === 0 ? ( - + {searchTerm ? 'No tests found matching your search' : 'No tests available'} @@ -467,14 +475,13 @@ export const CippAddTestReportDrawer = ({ {test.description && ( + overflow: 'hidden' + }}> {test.description} )} @@ -483,7 +490,7 @@ export const CippAddTestReportDrawer = ({ - ) + ); })} )} @@ -493,5 +500,5 @@ export const CippAddTestReportDrawer = ({ - ) + ); } diff --git a/src/components/CippComponents/CippAddTransportRuleDrawer.jsx b/src/components/CippComponents/CippAddTransportRuleDrawer.jsx index b9ee583100a8..0c906538bd1a 100644 --- a/src/components/CippComponents/CippAddTransportRuleDrawer.jsx +++ b/src/components/CippComponents/CippAddTransportRuleDrawer.jsx @@ -1,8 +1,8 @@ import { useState, useEffect } from "react"; import { Button, Divider } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { Grid } from "@mui/system"; import { useForm, useFormState, useWatch } from "react-hook-form"; -import { RocketLaunch } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; @@ -78,9 +78,9 @@ export const CippAddTransportRuleDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAddUserDrawer.jsx b/src/components/CippComponents/CippAddUserDrawer.jsx index 49594f4ddc52..7866178d2880 100644 --- a/src/components/CippComponents/CippAddUserDrawer.jsx +++ b/src/components/CippComponents/CippAddUserDrawer.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Button, Box } from "@mui/material"; import { useForm, useWatch, useFormState } from "react-hook-form"; -import { PersonAdd } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import { CippApiResults } from "./CippApiResults"; import { useSettings } from "../../hooks/use-settings"; @@ -102,7 +102,14 @@ export const CippAddUserDrawer = ({ }); }; - const handleCloseDrawer = () => { + const handleCloseDrawer = (event, reason) => { + // Closing resets the form, so a stray backdrop click or Escape would silently wipe + // everything typed so far (#390). A dirty-check is no help: the domain selector + // auto-picks the default domain on open, so the form is dirty before the user types. + // Ignore those dismissals — the X and Close buttons call this without a reason. + if (reason === "backdropClick" || reason === "escapeKeyDown") { + return; + } setDrawerVisible(false); const resetValues = { tenantFilter: userSettingsDefaults.currentTenant, @@ -136,9 +143,9 @@ export const CippAddUserDrawer = ({ return ( <> } + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippAlertSnoozeDialog.jsx b/src/components/CippComponents/CippAlertSnoozeDialog.jsx index db8ced18cec8..b89bb2b5fbec 100644 --- a/src/components/CippComponents/CippAlertSnoozeDialog.jsx +++ b/src/components/CippComponents/CippAlertSnoozeDialog.jsx @@ -86,7 +86,9 @@ export const CippAlertSnoozeDialog = ({ bgcolor: 'action.hover', }} > - + {alertLabel} {fields.length > 0 ? ( @@ -95,9 +97,11 @@ export const CippAlertSnoozeDialog = ({ + sx={{ + color: "text.secondary", + minWidth: 104, + flexShrink: 0 + }}> {field.label} @@ -115,7 +119,12 @@ export const CippAlertSnoozeDialog = ({ )} {!submitted ? ( - + Choose how long to snooze this specific alert item. It will not trigger notifications until the snooze expires. @@ -153,5 +162,5 @@ export const CippAlertSnoozeDialog = ({ )} - ) + ); } diff --git a/src/components/CippComponents/CippAliasDialog.jsx b/src/components/CippComponents/CippAliasDialog.jsx index 7f6696054843..d5ef19b5e884 100644 --- a/src/components/CippComponents/CippAliasDialog.jsx +++ b/src/components/CippComponents/CippAliasDialog.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Typography, Box, Button, TextField, Chip, Stack } from "@mui/material"; -import { Add } from "@mui/icons-material"; import { useWatch } from "react-hook-form"; import { CippFormDomainSelector } from "./CippFormDomainSelector"; @@ -77,7 +77,9 @@ const CippAliasDialog = ({ formHook }) => { return ( <> - + Add proxy addresses (aliases) for this user. Enter a prefix, choose a verified tenant domain, and click Add or press Enter. @@ -121,7 +123,7 @@ const CippAliasDialog = ({ formHook }) => { onClick={handleAddAlias} variant="contained" disabled={!aliasPrefix.trim() || !selectedDomainValue || isPending} - startIcon={} + startIcon={} size="small" > Add @@ -144,14 +146,13 @@ const CippAliasDialog = ({ formHook }) => { {aliasList.length === 0 ? ( + width: "100%" + }}> No aliases added yet ) : ( diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx index 2f2603a69354..729a58160b9e 100644 --- a/src/components/CippComponents/CippApiDialog.jsx +++ b/src/components/CippComponents/CippApiDialog.jsx @@ -149,8 +149,10 @@ export const CippApiDialog = (props) => { const tenantFilter = useSettings().currentTenant - const handleActionClick = (row, action, formData) => { + const handleActionClick = (row, action, rawFormData) => { setIsFormSubmitted(true) + // The typed-confirmation field only gates the submit button; it never reaches the API. + const { __confirmPhrase, ...formData } = rawFormData ?? {} const resolvedFormData = mergeCsvFormFields(formData, fields) let finalData = {} let isBulkRequest = false @@ -313,14 +315,23 @@ export const CippApiDialog = (props) => { !linkOpenedRef.current ) { linkOpenedRef.current = true - const linkWithData = api.link.replace( - /\[([^\]]+)\]/g, - (_, key) => getRawNestedValue(row, key) || `[${key}]` - ) - if (linkWithData.startsWith('/') && !api?.external) { - router.push(linkWithData, undefined, { shallow: true }) + const placeholder = /\[([^\]]+)\]/g + const hasValue = (value) => value !== undefined && value !== null && value !== '' + if (api.link.startsWith('/') && !api?.external) { + // Internal routes only ever substitute ids and query values, so encode them: the row + // is tenant data and must not be able to inject path segments or a second origin. + const internalLink = api.link.replace(placeholder, (_, key) => { + const value = getRawNestedValue(row, key) + return hasValue(value) ? encodeURIComponent(String(value)) : `[${key}]` + }) + router.push(internalLink, undefined, { shallow: true }) } else { - window.open(linkWithData, api.target || '_blank') + // External links may substitute a whole URL (e.g. [webUrl]) and are left as-is. + const externalLink = api.link.replace(placeholder, (_, key) => { + const value = getRawNestedValue(row, key) + return hasValue(value) ? value : `[${key}]` + }) + window.open(externalLink, api.target || '_blank') } createDialog.handleClose() } @@ -370,9 +381,9 @@ export const CippApiDialog = (props) => { : element.replace( /\[([^\]]+)\]/g, (_, key) => getNestedValue(row[0], key) || `[${key}]` - ) + ); } - return element.replace(/\[([^\]]+)\]/g, (_, key) => getNestedValue(row, key) || `[${key}]`) + return element.replace(/\[([^\]]+)\]/g, (_, key) => getNestedValue(row, key) || `[${key}]`); } if (React.isValidElement(element)) { const newChildren = React.Children.map(element.props.children, replaceTextInElement) @@ -383,6 +394,27 @@ export const CippApiDialog = (props) => { confirmText = replaceTextInElement(api?.confirmText) } + // Optional typed confirmation: api.confirmPhrase is a string (with [field] interpolation from + // the row) or a function of the row / selected rows returning the phrase, or null/'' to skip. + // While set, the Confirm button stays disabled until the user types the phrase exactly. + let confirmPhrase = null + if (api?.confirmPhrase) { + if (typeof api.confirmPhrase === 'function') { + confirmPhrase = api.confirmPhrase(row) + } else if (Array.isArray(row)) { + confirmPhrase = + row.length > 1 + ? `CONFIRM ${row.length} ITEMS` + : api.confirmPhrase.replace(/\[([^\]]+)\]/g, (_, key) => getNestedValue(row[0], key) || '') + } else { + confirmPhrase = api.confirmPhrase.replace( + /\[([^\]]+)\]/g, + (_, key) => getNestedValue(row, key) || '' + ) + } + if (typeof confirmPhrase !== 'string' || confirmPhrase.trim() === '') confirmPhrase = null + } + return ( <> {!api?.link && ( @@ -503,6 +535,22 @@ export const CippApiDialog = (props) => { )} + {confirmPhrase && ( + + + (value ?? '').trim() === confirmPhrase || + `Type ${confirmPhrase} exactly to enable Confirm`, + }} + /> + + )} diff --git a/src/components/CippComponents/CippApiLogsDrawer.jsx b/src/components/CippComponents/CippApiLogsDrawer.jsx index e74c5ebbbaf6..3e7d254e2ff1 100644 --- a/src/components/CippComponents/CippApiLogsDrawer.jsx +++ b/src/components/CippComponents/CippApiLogsDrawer.jsx @@ -1,7 +1,6 @@ import { useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Button, Box } from '@mui/material' -import { ReceiptLongOutlined } from '@mui/icons-material' -import { EyeIcon } from '@heroicons/react/24/outline' import { CippOffCanvas } from './CippOffCanvas' import { CippDataTable } from '../CippTable/CippDataTable' @@ -27,12 +26,15 @@ export const CippApiLogsDrawer = ({ setDrawerVisible(true) } - // Build the API URL with the filter + // Build the API URL with the filter. Scoped drawers (a standard template, a scheduled task or a + // baseline run) cover the last 7 days: their runs are scheduled, so "today only" is empty for + // most of the day after a run that finished overnight. + const isScoped = Boolean(standardFilter || scheduledTaskFilter || baselineRunFilter) const apiUrl = `/api/ListLogs?Filter=true${apiFilter ? `&API=${apiFilter}` : ''}${ tenantFilter ? `&Tenant=${tenantFilter}` : '' }${standardFilter ? `&StandardTemplateId=${standardFilter}` : ''}${ scheduledTaskFilter ? `&ScheduledTaskId=${scheduledTaskFilter}` : '' - }${baselineRunFilter ? `&BaselineRunId=${baselineRunFilter}` : ''}` + }${baselineRunFilter ? `&BaselineRunId=${baselineRunFilter}` : ''}${isScoped ? '&Days=7' : ''}` // Define the columns for the logs table const simpleColumns = [ @@ -52,7 +54,8 @@ export const CippApiLogsDrawer = ({ { label: 'View Log Entry', link: '/cipp/logs/logentry?logentry=[RowKey]', - icon: , + pinned: true, + icon: , color: 'primary', }, ] @@ -62,7 +65,7 @@ export const CippApiLogsDrawer = ({ } + startIcon={} {...props} > {buttonText} diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx index f14e6a489f7b..e4c090e009d9 100644 --- a/src/components/CippComponents/CippApiResults.jsx +++ b/src/components/CippComponents/CippApiResults.jsx @@ -1,13 +1,3 @@ -import { - Close, - Download, - Help, - ExpandMore, - ExpandLess, - CheckCircle, - Error as ErrorIcon, - RadioButtonUnchecked, -} from '@mui/icons-material' import { Alert, Chip, @@ -22,7 +12,8 @@ import { Button, keyframes, } from '@mui/material' -import { useEffect, useState, useMemo, useCallback } from 'react' +import { CippIcons } from '../../utils/icon-registry' +import { useEffect, useState, useMemo, useCallback, useRef } from 'react' import { ApiGetCall } from '../../api/ApiCall' import { getCippError } from '../../utils/get-cipp-error' import { CippCopyToClipBoard } from './CippCopyToClipboard' @@ -30,7 +21,7 @@ import { CippDocsLookup } from './CippDocsLookup' import { CippCodeBlock } from './CippCodeBlock' import React from 'react' import { CippTableDialog } from './CippTableDialog' -import { EyeIcon } from '@heroicons/react/24/outline' +import { CippJobProgress, formatJobProgressText } from './CippJobProgress' import { useDialog } from '../../hooks/use-dialog' const extractAllResults = (data, extraIgnoreKeys = []) => { @@ -38,7 +29,7 @@ const extractAllResults = (data, extraIgnoreKeys = []) => { const getSeverity = (text) => { if (typeof text !== 'string') return 'success' - return /error|failed|exception|not found|invalid_grant/i.test(text) ? 'error' : 'success' + return /error|failed|exception|not found|invalid_grant/i.test(text) ? 'error' : 'success'; } const processResultItem = (item) => { @@ -131,59 +122,6 @@ const extractAllResults = (data, extraIgnoreKeys = []) => { return results } -const capitalize = (text) => - typeof text === 'string' && text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : text - -const JOB_STATUS_CHIP_COLORS = { - queued: 'default', - running: 'info', - succeeded: 'success', - failed: 'error', -} - -// Status icon for a single job step. -const JobStepIcon = ({ status }) => { - if (status === 'succeeded') return - if (status === 'failed') return - if (status === 'running') return - return -} - -// Live job progress rows (GDAP-onboarding style): one block per row (usually a tenant) with -// its steps, driven by the jobProgress polling in CippApiResults. -const CippJobProgress = ({ rows }) => ( - - {rows.map((row, rowIndex) => ( - - - {row.Tenant ?? row.Name} - - - - {(row.Steps || []).map((step, index) => ( - - - - - - {step.Title} - - {step.Message} - - - - ))} - - - ))} - -) - export const CippApiResults = (props) => { const { apiObject, errorsOnly = false, alertSx = {}, jobProgress = null } = props @@ -223,15 +161,22 @@ export const CippApiResults = (props) => { staleTime: 0, }) const jobRows = Array.isArray(jobStatus.data) ? jobStatus.data : [] + // After a re-run the finished rows stay as they are until the job rewrites them, so keep polling + // until a row goes active again, or give up after 90 s if the re-run never started. + const restartedAt = useRef(null) + const handleRerun = useCallback(() => { + restartedAt.current = Date.now() + setJobPollActive(true) + }, []) useEffect(() => { - if ( - jobPollActive && - jobRows.length > 0 && - jobRows.every((row) => row.Status === 'succeeded' || row.Status === 'failed') - ) { - setJobPollActive(false) + if (!jobPollActive || jobRows.length === 0) return + if (jobRows.some((row) => row.Status !== 'succeeded' && row.Status !== 'failed')) { + restartedAt.current = null + return } - }, [jobPollActive, jobRows]) + if (restartedAt.current && Date.now() - restartedAt.current < 90000) return + setJobPollActive(false) + }, [jobPollActive, jobRows, jobStatus.dataUpdatedAt]) const pageTitle = `${document.title} - Results` const correctResultObj = useMemo(() => { if (!apiObject.isSuccess) return @@ -339,6 +284,10 @@ export const CippApiResults = (props) => { setFinalResults((prev) => prev.map((r) => (r.id === id ? { ...r, visible: false } : r))) }, []) + const handleCloseAllResults = useCallback(() => { + setFinalResults((prev) => prev.map((r) => ({ ...r, visible: false }))) + }, []) + const toggleDetails = useCallback((id) => { setShowDetails((prev) => ({ ...prev, [id]: !prev[id] })) }, []) @@ -385,7 +334,7 @@ export const CippApiResults = (props) => { size="small" onClick={() => setFetchingVisible(false)} > - + } variant="outlined" @@ -405,6 +354,18 @@ export const CippApiResults = (props) => { severity={ failedActionCount === 0 ? 'success' : successActionCount === 0 ? 'error' : 'warning' } + action={ + + + + + + } > {failedActionCount === 0 @@ -446,7 +407,7 @@ export const CippApiResults = (props) => { size="small" variant="contained" color="secondary" - startIcon={} + startIcon={} onClick={() => { const searchUrl = `https://docs.cipp.app/?q=Help+with:+${encodeURIComponent( resultObj.copyField || resultObj.text @@ -487,9 +448,9 @@ export const CippApiResults = (props) => { aria-label={showDetails[resultObj.id] ? 'Hide Details' : 'Show Details'} > {showDetails[resultObj.id] ? ( - + ) : ( - + )} @@ -501,7 +462,7 @@ export const CippApiResults = (props) => { size="small" onClick={() => handleCloseResult(resultObj.id)} > - + } @@ -510,7 +471,11 @@ export const CippApiResults = (props) => { {resultObj.text} {resultObj.details && ( - + { {(apiObject.isSuccess || apiObject.isError) && finalResults?.length > 0 && hasVisibleResults ? ( - + tableDialog.handleOpen()}> - + - + @@ -553,16 +522,31 @@ export const CippApiResults = (props) => { {/* Live job progress (opt-in via the jobProgress prop) */} {jobProgress && jobId && ( - + {jobProgress.title ?? 'Progress'} {jobPollActive && } + {jobRows.length > 0 && ( + + )} {jobRows.length === 0 ? ( - + Waiting for the first status update... ) : ( - + )} )} @@ -576,5 +560,5 @@ export const CippApiResults = (props) => { /> )} - ) + ); } diff --git a/src/components/CippComponents/CippAppApprovalTemplateDrawer.jsx b/src/components/CippComponents/CippAppApprovalTemplateDrawer.jsx index 5315e080eb5a..aec705d5c8f8 100644 --- a/src/components/CippComponents/CippAppApprovalTemplateDrawer.jsx +++ b/src/components/CippComponents/CippAppApprovalTemplateDrawer.jsx @@ -1,8 +1,8 @@ import { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Button } from "@mui/material"; import { Grid } from "@mui/system"; import { useForm } from "react-hook-form"; -import { Add, Edit } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import AppApprovalTemplateForm from "./AppApprovalTemplateForm"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; @@ -127,9 +127,9 @@ export const CippAppApprovalTemplateDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={isEditMode ? : } + startIcon={isEditMode ? : } > {buttonText} diff --git a/src/components/CippComponents/CippAppPermissionBuilder.jsx b/src/components/CippComponents/CippAppPermissionBuilder.jsx index ffc1b49bf348..c429ce06f39d 100644 --- a/src/components/CippComponents/CippAppPermissionBuilder.jsx +++ b/src/components/CippComponents/CippAppPermissionBuilder.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useCallback } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Box, Button, @@ -19,21 +20,7 @@ import { import { Grid } from "@mui/system"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { CippDataTable } from "../CippTable/CippDataTable"; -import { PlusIcon, ShieldCheckIcon, WrenchIcon } from "@heroicons/react/24/outline"; import CippFormComponent from "./CippFormComponent"; -import { - Apps, - Delete, - Download, - Error, - ExpandMore, - Save, - Sync, - TaskAlt, - Undo, - Upload, - WarningAmberOutlined, -} from "@mui/icons-material"; import { useWatch } from "react-hook-form"; import { CippCardTabPanel } from "./CippCardTabPanel"; import { CippApiResults } from "./CippApiResults"; @@ -610,7 +597,7 @@ const CippAppPermissionBuilder = ({ > @@ -625,7 +612,7 @@ const CippAppPermissionBuilder = ({ actions={[ { label: "Delete Permission", - icon: , + icon: , noConfirm: true, condition: (row) => !row.required, customFunction: (row) => handleRemoveRow("applicationPermissions", row), @@ -636,7 +623,7 @@ const CippAppPermissionBuilder = ({ ) : ( - } sx={{ mb: 3 }}> + } sx={{ mb: 3 }}> No Application Permissions found. )} @@ -644,7 +631,7 @@ const CippAppPermissionBuilder = ({ {spInfo?.Results?.publishedPermissionScopes?.length === 0 && ( - }> + }> No Published Delegated Permissions found. )} @@ -674,7 +661,7 @@ const CippAppPermissionBuilder = ({ > @@ -691,7 +678,7 @@ const CippAppPermissionBuilder = ({ actions={[ { label: "Delete Permission", - icon: , + icon: , noConfirm: true, condition: (row) => !row.required, customFunction: (row) => handleRemoveRow("delegatedPermissions", row), @@ -704,7 +691,7 @@ const CippAppPermissionBuilder = ({ @@ -800,7 +791,7 @@ const CippAppPermissionBuilder = ({ variant="outlined" > - + @@ -812,7 +803,7 @@ const CippAppPermissionBuilder = ({ }} > - + @@ -825,7 +816,7 @@ const CippAppPermissionBuilder = ({ }} > - + @@ -873,7 +864,7 @@ const CippAppPermissionBuilder = ({ {manifestError && ( - } sx={{ mt: 4 }}> + } sx={{ mt: 4 }}> Invalid manifest. Please ensure the manifest is in the correct format. )} @@ -881,7 +872,7 @@ const CippAppPermissionBuilder = ({ <> - }> + }> Manifest is valid. Click Import to apply the permissions. @@ -891,7 +882,7 @@ const CippAppPermissionBuilder = ({ onClick={() => importManifest()} startIcon={ - + } > @@ -925,7 +916,7 @@ const CippAppPermissionBuilder = ({ {isDeprecatedSp && ( - }> + }> {currentSelectedSp.label} is deprecated and cannot be added. Please select a different service principal. @@ -965,7 +956,7 @@ const CippAppPermissionBuilder = ({ {hasMissing && ( - }> + }> Permissions missing from the {appDisplayName} app registration (run Repair Permissions to add, then a CPV refresh to apply to tenants) @@ -982,7 +973,7 @@ const CippAppPermissionBuilder = ({ )} {hasExtra && ( - }> + }> Extra permissions present on the {appDisplayName} app registration that are not part of the CIPP defaults or your additional permissions @@ -1014,7 +1005,7 @@ const CippAppPermissionBuilder = ({ slotProps={{ transition: { unmountOnExit: true } }} > } + expandIcon={} // Flex children default to min-width:auto, so without this the // 36-character app-id chip refuses to shrink and pushes the whole // summary — display name first — off the left edge of a phone. @@ -1023,11 +1014,14 @@ const CippAppPermissionBuilder = ({ + sx={{ + justifyContent: "space-between", + alignItems: { xs: "flex-start", md: "center" }, + width: "100%", + mr: 1, + minWidth: 0 + }}> + maxWidth: "100%" + }}> - + } onClick={(e) => { @@ -1082,7 +1075,7 @@ const CippAppPermissionBuilder = ({ sx={{ width: "100px", flexShrink: 0 }} icon={ - + } /> @@ -1105,7 +1098,7 @@ const CippAppPermissionBuilder = ({ color="error" > - + @@ -1136,7 +1129,7 @@ const CippAppPermissionBuilder = ({ variant="contained" startIcon={ - + } type="submit" diff --git a/src/components/CippComponents/CippAppRegistrationPermissions.jsx b/src/components/CippComponents/CippAppRegistrationPermissions.jsx index 6d432c3d5103..01fbec26b314 100644 --- a/src/components/CippComponents/CippAppRegistrationPermissions.jsx +++ b/src/components/CippComponents/CippAppRegistrationPermissions.jsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Accordion, AccordionSummary, @@ -14,7 +15,6 @@ import { Tooltip, Typography, } from "@mui/material"; -import { ExpandMore, WarningAmber } from "@mui/icons-material"; import { useTheme } from "@mui/material/styles"; import { ApiGetCall } from "../../api/ApiCall"; import riskyPermissionsJson from "../../data/RiskyPermissions.json"; @@ -162,7 +162,7 @@ const ResourcePermissionsAccordion = ({ disableGutters > } + expandIcon={} // summary is a centered ButtonBase, an unshrinkable row spills both edges sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }} > @@ -170,9 +170,13 @@ const ResourcePermissionsAccordion = ({ direction="row" spacing={2} useFlexGap - alignItems="center" - sx={{ width: "100%", pr: 1, flexWrap: "wrap", rowGap: 1 }} - > + sx={{ + alignItems: "center", + width: "100%", + pr: 1, + flexWrap: "wrap", + rowGap: 1 + }}> {title} @@ -182,7 +186,7 @@ const ResourcePermissionsAccordion = ({ > } + icon={} label={`${accordionRisk.worst} (${accordionRisk.count})`} color={riskChipColor(accordionRisk.worst)} variant={accordionRisk.worst === "Low" ? "outlined" : "filled"} @@ -259,9 +263,10 @@ const ResourcePermissionsAccordion = ({ + slotProps={{ + primary: { variant: "body2", fontWeight: "medium" }, + secondary: { variant: "caption" } + }} /> ); })} @@ -357,7 +362,12 @@ const CippAppRegistrationPermissions = ({ requiredResourceAccess }) => { Application permissions - + App-only (admin consent) permissions, grouped by resource API. {listBusy && } @@ -381,7 +391,12 @@ const CippAppRegistrationPermissions = ({ requiredResourceAccess }) => { Delegated permissions - + Delegated (user or admin consent) permissions, grouped by resource API. {listBusy && } diff --git a/src/components/CippComponents/CippAppTemplateDrawer.jsx b/src/components/CippComponents/CippAppTemplateDrawer.jsx index 1d3dca33c80a..93973f09e690 100644 --- a/src/components/CippComponents/CippAppTemplateDrawer.jsx +++ b/src/components/CippComponents/CippAppTemplateDrawer.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useCallback, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Button, Divider, @@ -13,7 +14,6 @@ import { } from '@mui/material' import { Grid } from '@mui/system' import { useForm, useWatch } from 'react-hook-form' -import { Add, Delete, Edit, Save } from '@mui/icons-material' import { CippOffCanvas } from './CippOffCanvas' import CippFormComponent from './CippFormComponent' import { CippFormCondition } from './CippFormCondition' @@ -26,6 +26,7 @@ const appTypeLabels = { StoreApp: 'Store App', chocolateyApp: 'Chocolatey App', officeApp: 'Microsoft Office', + edgeApp: 'Microsoft Edge', win32ScriptApp: 'Custom Application', } @@ -146,6 +147,7 @@ export const CippAppTemplateDrawer = ({ const type = formData.appType?.value if (type === 'mspApp') return formData.displayName || formData.rmmname?.label || 'MSP App' if (type === 'officeApp') return 'Microsoft 365 Apps' + if (type === 'edgeApp') return 'Microsoft Edge' return formData.applicationName || formData.packagename || 'Unnamed App' } @@ -282,7 +284,7 @@ export const CippAppTemplateDrawer = ({ return ( <> {!onClose && ( - )} @@ -298,7 +300,7 @@ export const CippAppTemplateDrawer = ({ color="primary" onClick={handleSaveTemplate} disabled={getTotalApps() === 0 || saveTemplate.isPending} - startIcon={} + startIcon={} > {saveTemplate.isPending ? 'Saving...' @@ -353,10 +355,10 @@ export const CippAppTemplateDrawer = ({ handleEditApp(index)} size="small"> - + handleRemoveApp(index)} size="small"> - + @@ -383,6 +385,7 @@ export const CippAppTemplateDrawer = ({ { label: 'Store App', value: 'StoreApp' }, { label: 'Chocolatey App', value: 'chocolateyApp' }, { label: 'Microsoft Office', value: 'officeApp' }, + { label: 'Microsoft Edge', value: 'edgeApp' }, { label: 'Custom Application', value: 'win32ScriptApp' }, ]} multiple={false} @@ -615,6 +618,13 @@ export const CippAppTemplateDrawer = ({ /> + + {/* Edge App Fields */} + + + + + + ({ + value: tag, + label: `${language} (${tag})`, + }))} + multiple={false} + formControl={formControl} + /> + + + {/* Win32 Script App Fields */} - diff --git a/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/src/components/CippComponents/CippApplicationDeployDrawer.jsx index ec7855ce419c..d125c275a7dd 100644 --- a/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -1,8 +1,8 @@ import React, { useEffect, useCallback, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Divider, Button, Alert, CircularProgress } from '@mui/material' import { Grid } from '@mui/system' import { useForm, useWatch } from 'react-hook-form' -import { Add } from '@mui/icons-material' import { CippOffCanvas } from './CippOffCanvas' import CippFormComponent from './CippFormComponent' import { CippFormTenantSelector } from './CippFormTenantSelector' @@ -60,6 +60,7 @@ export const CippApplicationDeployDrawer = ({ winGetApp: '/api/AddwinGetApp', chocolateyApp: '/api/AddChocoApp', officeApp: '/api/AddOfficeApp', + edgeApp: '/api/AddEdgeApp', win32ScriptApp: '/api/AddWin32ScriptApp', } @@ -118,9 +119,9 @@ export const CippApplicationDeployDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} @@ -161,6 +162,7 @@ export const CippApplicationDeployDrawer = ({ // uncomment after release { label: "WinGet App", value: "winGetApp" }, { label: 'Chocolatey App', value: 'chocolateyApp' }, { label: 'Microsoft Office', value: 'officeApp' }, + { label: 'Microsoft Edge', value: 'edgeApp' }, { label: 'Custom Application', value: 'win32ScriptApp' }, ]} multiple={false} @@ -381,10 +383,10 @@ export const CippApplicationDeployDrawer = ({ type="radio" name="AssignTo" options={[ - { label: 'Do not assign', value: 'On' }, - { label: 'Assign to all users', value: 'allLicensedUsers' }, - { label: 'Assign to all devices', value: 'AllDevices' }, - { label: 'Assign to all users and devices', value: 'AllDevicesAndUsers' }, + { label: 'Do Not Assign', value: 'On' }, + { label: 'Assign to All Users', value: 'allLicensedUsers' }, + { label: 'Assign to All Devices', value: 'AllDevices' }, + { label: 'Assign to All Users and Devices', value: 'AllDevicesAndUsers' }, { label: 'Assign to Custom Group', value: 'customGroup' }, ]} formControl={formControl} @@ -519,10 +521,10 @@ export const CippApplicationDeployDrawer = ({ type="radio" name="AssignTo" options={[ - { label: 'Do not assign', value: 'On' }, - { label: 'Assign to all users', value: 'allLicensedUsers' }, - { label: 'Assign to all devices', value: 'AllDevices' }, - { label: 'Assign to all users and devices', value: 'AllDevicesAndUsers' }, + { label: 'Do Not Assign', value: 'On' }, + { label: 'Assign to All Users', value: 'allLicensedUsers' }, + { label: 'Assign to All Devices', value: 'AllDevices' }, + { label: 'Assign to All Users and Devices', value: 'AllDevicesAndUsers' }, { label: 'Assign to Custom Group', value: 'customGroup' }, ]} formControl={formControl} @@ -680,10 +682,10 @@ export const CippApplicationDeployDrawer = ({ type="radio" name="AssignTo" options={[ - { label: 'Do not assign', value: 'On' }, - { label: 'Assign to all users', value: 'allLicensedUsers' }, - { label: 'Assign to all devices', value: 'AllDevices' }, - { label: 'Assign to all users and devices', value: 'AllDevicesAndUsers' }, + { label: 'Do Not Assign', value: 'On' }, + { label: 'Assign to All Users', value: 'allLicensedUsers' }, + { label: 'Assign to All Devices', value: 'AllDevices' }, + { label: 'Assign to All Users and Devices', value: 'AllDevicesAndUsers' }, { label: 'Assign to Custom Group', value: 'customGroup' }, ]} formControl={formControl} @@ -858,10 +860,95 @@ export const CippApplicationDeployDrawer = ({ type="radio" name="AssignTo" options={[ - { label: 'Do not assign', value: 'On' }, - { label: 'Assign to all users', value: 'allLicensedUsers' }, - { label: 'Assign to all devices', value: 'AllDevices' }, - { label: 'Assign to all users and devices', value: 'AllDevicesAndUsers' }, + { label: 'Do Not Assign', value: 'On' }, + { label: 'Assign to All Users', value: 'allLicensedUsers' }, + { label: 'Assign to All Devices', value: 'AllDevices' }, + { label: 'Assign to All Users and Devices', value: 'AllDevicesAndUsers' }, + { label: 'Assign to Custom Group', value: 'customGroup' }, + ]} + formControl={formControl} + row + /> + + + + + + + + + + + + + + {/* Edge App Section */} + + + + + + ({ + value: tag, + label: `${language} (${tag})`, + }))} + multiple={false} + formControl={formControl} + /> + + + {/* Assign To Options */} + + - {/* icon is a sibling of the title, never nested inside it, so the heading stays a single unambiguous text node */} - + {titleIcon} {title} @@ -234,7 +240,14 @@ export const CippAuthShell = ({ ))} {(hasPrimary || hasSecondary) && ( - + {hasPrimary && (actionHref ? ( - ) + ); } export default CippCheckUserAccessDialog diff --git a/src/components/CippComponents/CippCopyToClipboard.jsx b/src/components/CippComponents/CippCopyToClipboard.jsx index 2be6d6aa2880..54bd42df2f81 100644 --- a/src/components/CippComponents/CippCopyToClipboard.jsx +++ b/src/components/CippComponents/CippCopyToClipboard.jsx @@ -1,5 +1,5 @@ -import { CopyAll, Visibility, VisibilityOff } from "@mui/icons-material"; import { Chip, IconButton, SvgIcon, Tooltip } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { useState } from "react"; export const CippCopyToClipBoard = (props) => { @@ -47,7 +47,7 @@ export const CippCopyToClipBoard = (props) => { - + @@ -75,7 +75,7 @@ export const CippCopyToClipBoard = (props) => { <> - {showPassword ? : } + {showPassword ? : } diff --git a/src/components/CippComponents/CippCoverageHeatmap.jsx b/src/components/CippComponents/CippCoverageHeatmap.jsx index 830ddde36e5c..b041700368c0 100644 --- a/src/components/CippComponents/CippCoverageHeatmap.jsx +++ b/src/components/CippComponents/CippCoverageHeatmap.jsx @@ -19,9 +19,14 @@ export const CippCoverageHeatmap = ({ rows = [], onCellClick }) => { if (!columns.length || !grid.length) { return ( - - No coverage data in this period. - + No coverage data in this period. + ); } @@ -76,9 +81,12 @@ export const CippCoverageHeatmap = ({ rows = [], onCellClick }) => { + sx={{ + color: "text.secondary", + fontSize: 10, + whiteSpace: "nowrap", + overflow: "visible" + }}> {i % headerStep === 0 ? fmtTime(ms) : ""} ))} diff --git a/src/components/CippComponents/CippCredentialExpandList.jsx b/src/components/CippComponents/CippCredentialExpandList.jsx index f1a36381f123..c778dbfbf329 100644 --- a/src/components/CippComponents/CippCredentialExpandList.jsx +++ b/src/components/CippComponents/CippCredentialExpandList.jsx @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import PropTypes from "prop-types"; import { Box, @@ -11,8 +12,6 @@ import { MenuItem, Typography, } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; import { useDispatch } from "react-redux"; import axios from "axios"; import { buildVersionedHeaders } from "../../utils/cippVersion"; @@ -164,7 +163,7 @@ export const CippCredentialExpandList = ({ const addSecretButton = canAddSecret ? ( - @@ -227,7 +226,9 @@ export const CippCredentialExpandList = ({ return ( {addSecretButton} - + No {credentialType === "password" ? "secrets" : "certificates"} configured. {addSecretDialog} @@ -257,7 +258,7 @@ export const CippCredentialExpandList = ({ setMenuCred(cred); }} > - + ) : null } @@ -265,9 +266,10 @@ export const CippCredentialExpandList = ({ + slotProps={{ + primary: { variant: "body2" }, + secondary: { variant: "caption" } + }} /> ); })} diff --git a/src/components/CippComponents/CippCsvExportButton.jsx b/src/components/CippComponents/CippCsvExportButton.jsx index 5a59aa4b1ea8..70070cd60100 100644 --- a/src/components/CippComponents/CippCsvExportButton.jsx +++ b/src/components/CippComponents/CippCsvExportButton.jsx @@ -1,5 +1,5 @@ -import { BackupTableTwoTone } from "@mui/icons-material"; import { IconButton, Tooltip } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { mkConfig, generateCsv, download } from "export-to-csv"; // Utility to flatten nested objects @@ -54,7 +54,7 @@ export const CippCsvExportButton = ({ rawData, reportName = "Export", includeFie - + diff --git a/src/components/CippComponents/CippCustomVariables.jsx b/src/components/CippComponents/CippCustomVariables.jsx index ac02e9bbcac1..924841e917e1 100644 --- a/src/components/CippComponents/CippCustomVariables.jsx +++ b/src/components/CippComponents/CippCustomVariables.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { useForm, useWatch } from 'react-hook-form' import { CardContent, @@ -14,14 +15,6 @@ import { TableRow, Typography, } from '@mui/material' -import { - PlusIcon, - TrashIcon, - PencilIcon, - GlobeAltIcon, - DocumentDuplicateIcon, - ArrowUturnLeftIcon, -} from '@heroicons/react/24/outline' import { CippDataTable } from '../CippTable/CippDataTable' import { CippApiResults } from './CippApiResults' import { CippApiDialog } from './CippApiDialog' @@ -56,15 +49,18 @@ const CippVariableUsage = ({ usage }) => { if (!usage) { return ( - - This variable is not defined anywhere else yet. - - ) + This variable is not defined anywhere else yet. + + ); } return ( - + { - ) + ); } const CippCustomVariables = ({ id }) => { @@ -281,7 +277,7 @@ const CippCustomVariables = ({ id }) => { label: 'Edit', icon: ( - + ), confirmText: "Update the custom variable '[RowKey]'?", @@ -327,7 +323,7 @@ const CippCustomVariables = ({ id }) => { label: 'Override for this tenant', icon: ( - + ), hideBulk: true, @@ -372,7 +368,7 @@ const CippCustomVariables = ({ id }) => { label: 'Revert to global', icon: ( - + ), hideBulk: true, @@ -395,7 +391,7 @@ const CippCustomVariables = ({ id }) => { label: 'View usage', icon: ( - + ), noConfirm: true, @@ -404,7 +400,7 @@ const CippCustomVariables = ({ id }) => { }, { label: 'Delete', - icon: , + icon: , confirmText: 'Are you sure you want to delete [RowKey]?', // An overridden row gets 'Revert to global' instead, which says what actually happens. condition: (row) => @@ -456,7 +452,7 @@ const CippCustomVariables = ({ id }) => { onClick={handleAddVariable} startIcon={ - + } > diff --git a/src/components/CippComponents/CippDateRangeFilter.jsx b/src/components/CippComponents/CippDateRangeFilter.jsx index cb5f4e0a2531..a23a398f303c 100644 --- a/src/components/CippComponents/CippDateRangeFilter.jsx +++ b/src/components/CippComponents/CippDateRangeFilter.jsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Accordion, AccordionSummary, @@ -6,7 +7,6 @@ import { Button, Typography, } from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { Grid } from "@mui/system"; import { useForm } from "react-hook-form"; import CippFormComponent from "./CippFormComponent"; @@ -35,7 +35,7 @@ export const CippDateRangeFilter = ({ const onSubmit = (data) => { if (data.dateFilter === "relative") { - onApply?.({ RelativeTime: `${data.Time}${data.Interval.value}`, StartDate: null, EndDate: null }); + onApply?.({ RelativeTime: `${data.Time ?? ""}${data.Interval.value}`, StartDate: null, EndDate: null }); } else if (data.dateFilter === "startEnd") { onApply?.({ RelativeTime: null, StartDate: data.startDate, EndDate: data.endDate }); } @@ -43,7 +43,7 @@ export const CippDateRangeFilter = ({ return ( setExpanded(!expanded)}> - }> + }> {title} diff --git a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx index 7cfc18fc1ee9..1b83c6756f1e 100644 --- a/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx +++ b/src/components/CippComponents/CippDeployCompliancePolicyDrawer.jsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Button, Divider, Stack } from "@mui/material"; import { Grid } from "@mui/system"; import { useForm, useFormState, useWatch } from "react-hook-form"; -import { RocketLaunch } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; @@ -218,9 +218,9 @@ export const CippDeployCompliancePolicyDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText ?? config.buttonLabel} @@ -230,7 +230,9 @@ export const CippDeployCompliancePolicyDrawer = ({ onClose={handleCloseDrawer} size="lg" footer={ - + - + Advanced Views reveal diagnostic pages (such as audit-log Search Coverage) that are hidden from day-to-day operations. This preference is per-user, stored in this browser. diff --git a/src/components/CippComponents/CippDocsLookup.jsx b/src/components/CippComponents/CippDocsLookup.jsx index 987809b24f2c..11048af7cacd 100644 --- a/src/components/CippComponents/CippDocsLookup.jsx +++ b/src/components/CippComponents/CippDocsLookup.jsx @@ -1,5 +1,5 @@ -import { Search } from "@mui/icons-material"; import { Chip, IconButton, SvgIcon, Tooltip } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { useState } from "react"; export const CippDocsLookup = (props) => { @@ -22,7 +22,7 @@ export const CippDocsLookup = (props) => { - + diff --git a/src/components/CippComponents/CippDomainServiceConfigurationRecords.jsx b/src/components/CippComponents/CippDomainServiceConfigurationRecords.jsx index a6a75fd2d4f3..93bce2889388 100644 --- a/src/components/CippComponents/CippDomainServiceConfigurationRecords.jsx +++ b/src/components/CippComponents/CippDomainServiceConfigurationRecords.jsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { useSettings } from "../../hooks/use-settings"; import { ApiGetCall } from "../../api/ApiCall"; import { @@ -13,7 +14,6 @@ import { Chip, CircularProgress, } from "@mui/material"; -import { ContentCopy, Check } from "@mui/icons-material"; const DnsRecordField = ({ label, value, copyable = true }) => { const [copied, setCopied] = useState(false); @@ -37,7 +37,7 @@ const DnsRecordField = ({ label, value, copyable = true }) => { {copyable && ( - {copied ? : } + {copied ? : } )} @@ -115,7 +115,9 @@ export const CippDomainServiceConfigurationRecords = ({ row }) => { + {record.label} diff --git a/src/components/CippComponents/CippDomainVerificationRecords.jsx b/src/components/CippComponents/CippDomainVerificationRecords.jsx index 2a8a741cd264..67c5ddf8c694 100644 --- a/src/components/CippComponents/CippDomainVerificationRecords.jsx +++ b/src/components/CippComponents/CippDomainVerificationRecords.jsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { useSettings } from "../../hooks/use-settings"; import { ApiGetCall } from "../../api/ApiCall"; import { @@ -13,7 +14,6 @@ import { Chip, CircularProgress, } from "@mui/material"; -import { ContentCopy, Check } from "@mui/icons-material"; const DnsRecordField = ({ label, value, copyable = true }) => { const [copied, setCopied] = useState(false); @@ -37,7 +37,7 @@ const DnsRecordField = ({ label, value, copyable = true }) => { {copyable && ( - {copied ? : } + {copied ? : } )} @@ -115,7 +115,9 @@ export const CippDomainVerificationRecords = ({ row }) => { + {record.label} diff --git a/src/pages/tenant/manage/driftManagementActions.js b/src/components/CippComponents/CippDriftManagementActions.jsx similarity index 89% rename from src/pages/tenant/manage/driftManagementActions.js rename to src/components/CippComponents/CippDriftManagementActions.jsx index 7b3f59c7bca0..41f202b9ad2a 100644 --- a/src/pages/tenant/manage/driftManagementActions.js +++ b/src/components/CippComponents/CippDriftManagementActions.jsx @@ -1,6 +1,6 @@ import React from 'react' -import { Edit, Sync, PlayArrow, PictureAsPdf } from '@mui/icons-material' -import { CippFormTemplateTenantSelector } from '../../../components/CippComponents/CippFormTemplateTenantSelector.jsx' +import { CippIcons } from '../../utils/icon-registry' +import { CippFormTemplateTenantSelector } from './CippFormTemplateTenantSelector.jsx' /** * Creates the standard drift management actions array @@ -23,7 +23,7 @@ export const createDriftManagementActions = ({ const actions = [ { label: 'Refresh Data', - icon: , + icon: , noConfirm: true, customFunction: onRefresh, }, @@ -33,7 +33,7 @@ export const createDriftManagementActions = ({ if (onGenerateReport) { actions.push({ label: 'Generate Report', - icon: , + icon: , noConfirm: true, customFunction: onGenerateReport, }) @@ -45,7 +45,8 @@ export const createDriftManagementActions = ({ if (showEditTemplate) { actions.push({ label: 'Edit Template', - icon: , + pinned: true, + icon: , color: 'info', noConfirm: true, customFunction: () => { @@ -68,7 +69,7 @@ export const createDriftManagementActions = ({ label: 'Run Standard Now', type: 'GET', url: '/api/ExecStandardsRun', - icon: , + icon: , data: { TemplateId: templateId, }, diff --git a/src/components/CippComponents/CippEnterpriseAppPermissions.jsx b/src/components/CippComponents/CippEnterpriseAppPermissions.jsx index 7b283ba99bde..8d74e97925de 100644 --- a/src/components/CippComponents/CippEnterpriseAppPermissions.jsx +++ b/src/components/CippComponents/CippEnterpriseAppPermissions.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Accordion, AccordionSummary, @@ -14,7 +15,6 @@ import { Tooltip, Typography, } from "@mui/material"; -import { ExpandMore, WarningAmber } from "@mui/icons-material"; import { useTheme } from "@mui/material/styles"; import { ApiPostCall } from "../../api/ApiCall"; import { getListGraphBulkRequestRows } from "../../utils/getListGraphBulkRequestRows.js"; @@ -169,9 +169,10 @@ const PermissionLine = ({ + slotProps={{ + primary: { variant: "body2", fontWeight: "medium" }, + secondary: { variant: "caption" } + }} /> ); }; @@ -192,7 +193,7 @@ const ResourceAccordion = ({ title, resourceId, chipLabel, children, riskSummary disableGutters > } + expandIcon={} // summary is a centered ButtonBase, an unshrinkable row spills both edges sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }} > @@ -200,9 +201,13 @@ const ResourceAccordion = ({ title, resourceId, chipLabel, children, riskSummary direction="row" spacing={2} useFlexGap - alignItems="center" - sx={{ width: "100%", pr: 1, flexWrap: "wrap", rowGap: 1 }} - > + sx={{ + alignItems: "center", + width: "100%", + pr: 1, + flexWrap: "wrap", + rowGap: 1 + }}> {title} @@ -212,7 +217,7 @@ const ResourceAccordion = ({ title, resourceId, chipLabel, children, riskSummary > } + icon={} label={`${riskSummary.worst} (${riskSummary.count})`} color={riskChipColor(riskSummary.worst)} variant={riskSummary.worst === "Low" ? "outlined" : "filled"} @@ -400,7 +405,12 @@ const CippEnterpriseAppPermissions = ({ servicePrincipalId, tenantFilter }) => { Application permissions - + App roles assigned to this enterprise application (app-only), grouped by resource API. {permLoading && } @@ -451,7 +461,12 @@ const CippEnterpriseAppPermissions = ({ servicePrincipalId, tenantFilter }) => { Delegated permissions - + OAuth2 delegated permission grants for this enterprise application, grouped by resource API. {permLoading && } diff --git a/src/components/CippComponents/CippEntitySwitcher.jsx b/src/components/CippComponents/CippEntitySwitcher.jsx index 215e7563da04..79c6d58b4349 100644 --- a/src/components/CippComponents/CippEntitySwitcher.jsx +++ b/src/components/CippComponents/CippEntitySwitcher.jsx @@ -1,4 +1,5 @@ import { useMemo, useRef, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { useRouter } from "next/router"; import { Box, @@ -13,7 +14,6 @@ import { Typography, } from "@mui/material"; import { visuallyHidden } from "@mui/utils"; -import { Check, KeyboardArrowDown, Search } from "@mui/icons-material"; import { ApiGetCall } from "../../api/ApiCall"; import { CippBottomSheet } from "./CippBottomSheet"; import { useIsMobileLayout } from "../../hooks/use-breakpoint"; @@ -102,12 +102,14 @@ export const CippEntitySwitcher = ({ placeholder={`Search ${entityNamePlural}...`} value={search} onChange={(event) => setSearch(event.target.value)} - InputProps={{ - startAdornment: ( - - - - ), + slotProps={{ + input: { + startAdornment: ( + + + + ), + } }} /> @@ -122,7 +124,13 @@ export const CippEntitySwitcher = ({ ))} {!listRequest.isFetching && filtered.length === 0 && ( - + No {entityNamePlural} match. )} @@ -137,12 +145,13 @@ export const CippEntitySwitcher = ({ + slotProps={{ + primary: { noWrap: true, variant: "body2", fontWeight: 500 }, + secondary: { noWrap: true, variant: "caption" } + }} /> {getId(row) === currentId && ( - + )} ))} @@ -181,13 +190,13 @@ export const CippEntitySwitcher = ({ switch {entityName} - + ) : ( <> {title} - diff --git a/src/components/CippComponents/CippErrorState.jsx b/src/components/CippComponents/CippErrorState.jsx index b9c906e4c9b2..6b9fce1c933e 100644 --- a/src/components/CippComponents/CippErrorState.jsx +++ b/src/components/CippComponents/CippErrorState.jsx @@ -1,8 +1,8 @@ import { useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import NextLink from 'next/link' import PropTypes from 'prop-types' import { Box, Button, Card, Collapse, Stack, Typography } from '@mui/material' -import { ExpandMoreOutlined } from '@mui/icons-material' // Shared card for the in-app error routes (404, 401, 500). Unlike CippImageCard, // which paints a hardcoded neutral.900 slab in both modes, this is built from @@ -45,9 +45,10 @@ export const CippErrorState = ({ + sx={{ + alignItems: "center", + p: { xs: 3, md: 5 } + }}> {imageUrl && ( setDetailOpen((open) => !open)} endIcon={ - + sx={{ + flexWrap: "wrap", + justifyContent: { xs: 'center', md: 'flex-start' }, + mt: 4 + }}> {hasPrimary && (actionHref ? ( - ) + ); } diff --git a/src/components/CippComponents/CippInfoTooltip.jsx b/src/components/CippComponents/CippInfoTooltip.jsx index e9c5a6b5adab..57c1513abd7a 100644 --- a/src/components/CippComponents/CippInfoTooltip.jsx +++ b/src/components/CippComponents/CippInfoTooltip.jsx @@ -1,5 +1,5 @@ import { IconButton, Tooltip } from "@mui/material"; -import { InfoOutlined } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; /** * Small info icon that shows a tooltip on hover. @@ -11,7 +11,7 @@ import { InfoOutlined } from "@mui/icons-material"; const CippInfoTooltip = ({ title, placement = "top", fontSize = 16 }) => ( - + ); diff --git a/src/components/CippComponents/CippIntuneDeviceActions.jsx b/src/components/CippComponents/CippIntuneDeviceActions.jsx index 8f56cbb7f3c8..d6be362c30f5 100644 --- a/src/components/CippComponents/CippIntuneDeviceActions.jsx +++ b/src/components/CippComponents/CippIntuneDeviceActions.jsx @@ -1,22 +1,5 @@ -import { EyeIcon } from '@heroicons/react/24/outline' -import { - Sync, - RestartAlt, - LocationOn, - Password, - PasswordOutlined, - Key, - Memory, - Edit, - Security, - FindInPage, - Shield, - AutoMode, - Recycling, - ManageAccounts, - GroupAdd, - RemoveModerator, -} from '@mui/icons-material' + +import { CippIcons } from '../../utils/icon-registry' // Shared between the MEM devices list page and the View Device detail page. // Link-type actions (View Device / View in Intune) render on the list page but are @@ -26,15 +9,17 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'View Device', link: `/endpoint/MEM/devices/device?deviceId=[id]`, + pinned: true, color: 'info', - icon: , + icon: , multiPost: false, }, { label: 'View in Intune', link: `https://intune.microsoft.com/${tenantFilter}/#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/overview/mdmDeviceId/[id]`, + pinned: true, color: 'info', - icon: , + icon: , target: '_blank', multiPost: false, external: true, @@ -42,7 +27,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Change Primary User', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -79,7 +64,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Add to Group', type: 'POST', - icon: , + icon: , url: '/api/EditGroup', customDataformatter: (row, action, formData) => { // Build the device list from selected devices - the backend resolves the Entra @@ -134,7 +119,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Rename Device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -153,7 +138,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Sync Device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -164,7 +149,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Reboot Device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -175,7 +160,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Locate Device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -186,93 +171,93 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Retrieve LAPS password', type: 'POST', - icon: , + icon: , url: '/api/ExecGetLocalAdminPassword', data: { GUID: 'azureADDeviceId', }, - condition: (row) => row.operatingSystem === 'Windows', + hideCondition: (row) => row.operatingSystem !== 'Windows', confirmText: 'Are you sure you want to retrieve the local admin password for [deviceName]?', }, { label: 'Rotate Local Admin Password', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', Action: 'RotateLocalAdminPassword', }, - condition: (row) => row.operatingSystem === 'Windows', + hideCondition: (row) => row.operatingSystem !== 'Windows', confirmText: 'Are you sure you want to rotate the password for [deviceName]?', }, { label: 'Retrieve BIOS Password', type: 'POST', - icon: , + icon: , url: '/api/ExecGetRecoveryKey', data: { // hardwarePasswordDetails is keyed on the Intune managedDevice id, not azureADDeviceId. GUID: 'id', RecoveryKeyType: '!BiosPassword', }, - condition: (row) => row.operatingSystem === 'Windows', + hideCondition: (row) => row.operatingSystem !== 'Windows', confirmText: 'Are you sure you want to retrieve the BIOS password for [deviceName]?', }, { label: 'Retrieve BitLocker Keys', type: 'POST', - icon: , + icon: , url: '/api/ExecGetRecoveryKey', data: { GUID: 'azureADDeviceId', RecoveryKeyType: '!BitLocker', }, - condition: (row) => row.operatingSystem === 'Windows', + hideCondition: (row) => row.operatingSystem !== 'Windows', confirmText: 'Are you sure you want to retrieve the BitLocker keys for [deviceName]?', }, { label: 'Retrieve FileVault Key', type: 'POST', - icon: , + icon: , url: '/api/ExecGetRecoveryKey', data: { GUID: 'id', RecoveryKeyType: '!FileVault', }, - condition: (row) => row.operatingSystem === 'macOS', + hideCondition: (row) => row.operatingSystem !== 'macOS', confirmText: 'Are you sure you want to retrieve the FileVault key for [deviceName]?', }, { label: 'Reset Passcode', type: 'POST', - icon: , + icon: , url: '/api/ExecDevicePasscodeAction', data: { GUID: 'id', Action: 'resetPasscode', }, - condition: (row) => row.operatingSystem === 'Android', + hideCondition: (row) => row.operatingSystem !== 'Android', confirmText: 'Are you sure you want to reset the passcode for [deviceName]? A new passcode will be generated and displayed.', }, { label: 'Remove Passcode', type: 'POST', - icon: , + icon: , url: '/api/ExecDevicePasscodeAction', data: { GUID: 'id', Action: 'resetPasscode', }, - condition: (row) => row.operatingSystem === 'iOS', + hideCondition: (row) => row.operatingSystem !== 'iOS', confirmText: 'Are you sure you want to remove the passcode from [deviceName]? This will remove the device passcode requirement.', }, { label: 'Windows Defender Full Scan', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -284,7 +269,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Windows Defender Quick Scan', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -296,7 +281,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Update Windows Defender', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -308,13 +293,13 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Offboard from Defender for Endpoint', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'azureADDeviceId', Action: 'offboardMDEDevice', }, - condition: (row) => row.operatingSystem === 'Windows', + hideCondition: (row) => row.operatingSystem !== 'Windows', confirmText: 'Are you sure you want to offboard [deviceName] from Microsoft Defender for Endpoint? This queues an offboarding action via the MDE API and cannot be undone without re-onboarding the device.', }, @@ -322,7 +307,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ // { // label: 'Generate logs and ship to MEM', // type: 'POST', - // icon: , + // icon: , // url: '/api/ExecDeviceAction', // data: { // GUID: 'id', @@ -333,95 +318,77 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ // 'Are you sure you want to generate logs for device [deviceName] and ship these to MEM?', // }, { - label: 'Fresh Start (Remove user data)', + label: 'Fresh Start', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', Action: 'cleanWindowsDevice', - keepUserData: false, }, - condition: (row) => row.operatingSystem === 'Windows', - confirmText: 'Are you sure you want to Fresh Start [deviceName]?', - }, - { - label: 'Fresh Start (Do not remove user data)', - type: 'POST', - icon: , - url: '/api/ExecDeviceAction', - data: { - GUID: 'id', - Action: 'cleanWindowsDevice', - keepUserData: true, - }, - condition: (row) => row.operatingSystem === 'Windows', + fields: [ + { + type: 'radio', + name: 'keepUserData', + label: 'User Data', + options: [ + { label: 'Keep user data', value: true }, + { label: 'Remove user data', value: false }, + ], + validators: { required: 'Please select an option' }, + }, + ], + hideCondition: (row) => row.operatingSystem !== 'Windows', confirmText: 'Are you sure you want to Fresh Start [deviceName]?', }, { - label: 'Wipe Device, keep enrollment data', - type: 'POST', - icon: , - url: '/api/ExecDeviceAction', - data: { - GUID: 'id', - Action: 'wipe', - keepUserData: false, - keepEnrollmentData: true, - }, - condition: (row) => row.operatingSystem === 'Windows', - confirmText: 'Are you sure you want to wipe [deviceName], and retain enrollment data?', - }, - { - label: 'Wipe Device, remove enrollment data', - type: 'POST', - icon: , - url: '/api/ExecDeviceAction', - data: { - GUID: 'id', - Action: 'wipe', - keepUserData: false, - keepEnrollmentData: false, - }, - condition: (row) => row.operatingSystem === 'Windows', - confirmText: 'Are you sure you want to wipe [deviceName], and remove enrollment data?', - }, - { - label: 'Wipe Device, keep enrollment data, and continue at powerloss', - type: 'POST', - icon: , - url: '/api/ExecDeviceAction', - data: { - GUID: 'id', - Action: 'wipe', - keepEnrollmentData: true, - keepUserData: false, - useProtectedWipe: true, - }, - condition: (row) => row.operatingSystem === 'Windows', - confirmText: - 'Are you sure you want to wipe [deviceName]? This will retain enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.', - }, - { - label: 'Wipe Device, remove enrollment data, and continue at powerloss', + label: 'Wipe Device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', Action: 'wipe', - keepEnrollmentData: false, keepUserData: false, - useProtectedWipe: true, }, - condition: (row) => row.operatingSystem === 'Windows', + fields: [ + { + type: 'radio', + name: 'keepEnrollmentData', + label: 'Enrollment Data', + options: [ + { + label: + 'Keep enrollment data (Autopilot Reset — device re-provisions through Autopilot)', + value: true, + }, + { label: 'Remove enrollment data (full retirement)', value: false }, + ], + validators: { required: 'Please select an option' }, + }, + { + type: 'radio', + name: 'useProtectedWipe', + label: 'Wipe Type', + options: [ + { label: 'Standard wipe', value: false }, + { + label: + 'Protected wipe — resumes if interrupted; may leave the device unbootable if it fails', + value: true, + }, + ], + validators: { required: 'Please select an option' }, + }, + ], + hideCondition: (row) => row.operatingSystem !== 'Windows', confirmText: - 'Are you sure you want to wipe [deviceName]? This will also remove enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.', + 'Are you sure you want to wipe [deviceName]? This removes all user data on the device. Use Fresh Start to keep user files.', }, { label: 'Wipe Device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -434,14 +401,14 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ label: 'Recovery PIN (optional, 6 digits)', }, ], - condition: (row) => row.operatingSystem === 'macOS', + hideCondition: (row) => row.operatingSystem !== 'macOS', confirmText: - 'Are you sure you want to wipe [deviceName]? This erases all content and settings and cannot be undone. Intel Macs without a T2 security chip require the recovery PIN to unlock the device after the wipe.', + 'Are you sure you want to wipe [deviceName]? This erases all content and settings and cannot be undone. Intel Macs without a T2 security chip require the recovery PIN to unlock the device after the wipe. This removes all user data on the device.', }, { label: 'Autopilot Reset', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -449,13 +416,14 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ keepUserData: false, keepEnrollmentData: true, }, - condition: (row) => row.operatingSystem === 'Windows', - confirmText: 'Are you sure you want to Autopilot Reset [deviceName]?', + hideCondition: (row) => row.operatingSystem !== 'Windows', + confirmText: + 'Are you sure you want to Autopilot Reset [deviceName]? This wipes the device and keeps enrollment data, removes all user data on the device, and the device will re-provision through Windows Autopilot.', }, { - label: 'Delete device', + label: 'Delete Device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -466,7 +434,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Retire device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -482,15 +450,17 @@ export const getBecIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'View Device', link: `/endpoint/MEM/devices/device?deviceId=[id]&tenantFilter=${tenantFilter}`, + pinned: true, color: 'info', - icon: , + icon: , multiPost: false, }, { label: 'View in Intune', link: `https://intune.microsoft.com/${tenantFilter}/#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/overview/mdmDeviceId/[id]`, + pinned: true, color: 'info', - icon: , + icon: , target: '_blank', multiPost: false, external: true, @@ -498,7 +468,7 @@ export const getBecIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Retire device', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', @@ -509,7 +479,7 @@ export const getBecIntuneDeviceActions = ({ tenantFilter } = {}) => [ { label: 'Wipe device (remove enrollment)', type: 'POST', - icon: , + icon: , url: '/api/ExecDeviceAction', data: { GUID: 'id', diff --git a/src/components/CippComponents/CippIntunePolicyActions.jsx b/src/components/CippComponents/CippIntunePolicyActions.jsx index 3544f6cdea93..d214b256cb2f 100644 --- a/src/components/CippComponents/CippIntunePolicyActions.jsx +++ b/src/components/CippComponents/CippIntunePolicyActions.jsx @@ -1,12 +1,4 @@ -import { Book, LaptopChromebook } from '@mui/icons-material' -import { - DocumentDuplicateIcon, - GlobeAltIcon, - PencilIcon, - TrashIcon, - UserIcon, - UserGroupIcon, -} from '@heroicons/react/24/outline' +import { CippIcons } from '../../utils/icon-registry' const assignmentModeOptions = [ { label: 'Replace existing assignments', value: 'replace' }, @@ -226,7 +218,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => URLName: policyType === 'URLName' ? 'URLName' : policyType, }, confirmText: 'Are you sure you want to create a template based on this policy?', - icon: , + icon: , color: 'info', multiPost: false, }) @@ -239,7 +231,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => type: 'POST', url: '/api/EditIntunePolicy', multiPost: false, - icon: , + icon: , color: 'info', data: { ID: 'id', @@ -273,7 +265,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => type: 'POST', url: '/api/AddIntunePolicyClone', multiPost: false, - icon: , + icon: , color: 'info', data: templateData || { ID: 'id', @@ -317,7 +309,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => fields: getBroadAssignFields(), customDataformatter: getCustomDataFormatter('allLicensedUsers'), confirmText: 'Are you sure you want to assign "[displayName]" to all users?', - icon: , + icon: , color: 'info', }) @@ -337,7 +329,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => fields: getBroadAssignFields(), customDataformatter: getCustomDataFormatter('AllDevices'), confirmText: 'Are you sure you want to assign "[displayName]" to all devices?', - icon: , + icon: , color: 'info', }) @@ -357,7 +349,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => fields: getBroadAssignFields(), customDataformatter: getCustomDataFormatter('AllDevicesAndUsers'), confirmText: 'Are you sure you want to assign "[displayName]" to all users and devices?', - icon: , + icon: , color: 'info', }) @@ -367,7 +359,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => type: 'POST', url: '/api/ExecAssignPolicy', allowResubmit: true, - icon: , + icon: , color: 'info', confirmText: 'Select the target groups for "[displayName]".', multiPost: false, @@ -386,7 +378,7 @@ export const useCippIntunePolicyActions = (tenant, policyType, options = {}) => URLName: deleteUrlName === 'URLName' ? 'URLName' : deleteUrlName, }, confirmText: 'Are you sure you want to delete this policy?', - icon: , + icon: , color: 'danger', }) } diff --git a/src/components/CippComponents/CippIntunePolicyDetails.jsx b/src/components/CippComponents/CippIntunePolicyDetails.jsx index b1820e1c47b8..5fed58bd97b1 100644 --- a/src/components/CippComponents/CippIntunePolicyDetails.jsx +++ b/src/components/CippComponents/CippIntunePolicyDetails.jsx @@ -35,13 +35,21 @@ export const CippIntunePolicyDetails = ({ row, tenant }) => { if (policyDetails.isLoading || policyDetails.isFetching) { return ( - + - + Loading policy details and Microsoft descriptions... - ) + ); } if (policyDetails.isError && !settingsObject) { diff --git a/src/components/CippComponents/CippIntuneSettingsEditor.jsx b/src/components/CippComponents/CippIntuneSettingsEditor.jsx index b346b89d08b4..4d7ceeff70c1 100644 --- a/src/components/CippComponents/CippIntuneSettingsEditor.jsx +++ b/src/components/CippComponents/CippIntuneSettingsEditor.jsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Alert, Box, @@ -10,7 +11,6 @@ import { Typography, } from '@mui/material' import { Grid } from '@mui/system' -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' import { useWatch } from 'react-hook-form' import CippFormComponent from './CippFormComponent' import { CippCopyToClipBoard } from './CippCopyToClipboard' @@ -142,7 +142,14 @@ const LeafDetails = ({ leaf, fieldPrefix, formControl, variableOptions }) => { return ( - + { > + sx={{ + color: "text.secondary", + maxWidth: '100%' + }}> {leaf.definitionId} @@ -173,9 +181,10 @@ const LeafDetails = ({ leaf, fieldPrefix, formControl, variableOptions }) => { {usesVariable && ( + sx={{ + color: "text.secondary", + display: "block" + }}> This setting uses a variable. Set the variable's value to{' '} {valueRequirementForLeaf(leaf)}. @@ -195,14 +204,18 @@ const LeafDetails = ({ leaf, fieldPrefix, formControl, variableOptions }) => { ))} {!usesVariable && rawValues.length > 0 && ( - - + sx={{ + alignItems: "center", + flexWrap: "wrap", + mb: accepted.length ? 1 : 0 + }}> + Stored as: {rawValues.map((value) => ( @@ -215,12 +228,15 @@ const LeafDetails = ({ leaf, fieldPrefix, formControl, variableOptions }) => { + sx={{ + color: "text.secondary", + display: "block" + }}> Accepted values — click to copy: - + {accepted.map((option) => ( { {!accepted.length && !usesVariable && ( + sx={{ + color: "text.secondary", + display: "block" + }}> A variable here must resolve to {valueRequirementForLeaf(leaf)}. )} - ) + ); } // Warns when a choice with dependent children is moved off its imported value, because the children @@ -305,9 +322,10 @@ const LeafField = ({ leaf, fieldPrefix, formControl, variableOptions }) => { {leaf.label} + sx={{ + color: "text.secondary", + fontStyle: 'italic' + }}> This setting type is not editable here. It is preserved exactly as imported. @@ -318,7 +336,7 @@ const LeafField = ({ leaf, fieldPrefix, formControl, variableOptions }) => { variableOptions={variableOptions} /> - ) + ); } if (leaf.kind === 'choice') { @@ -508,8 +526,12 @@ const CippIntuneSettingsEditor = ({ return ( - - + + {sections.length} categories, {leaves.length} settings toggleSection(section.key)} - sx={{ cursor: 'pointer', userSelect: 'none' }} - > - + {section.heading} - + {section.leaves.length} {showPath && ( + sx={{ + color: "text.secondary", + minWidth: 0, + opacity: 0.8 + }}> {path} )} @@ -587,10 +615,10 @@ const CippIntuneSettingsEditor = ({ - ) + ); })} - ) + ); } // One shared reference for the nothing-to-resolve case, so the hook below is not handed a fresh diff --git a/src/components/CippComponents/CippInviteGuestDrawer.jsx b/src/components/CippComponents/CippInviteGuestDrawer.jsx index badfaaa01567..ed63099b6795 100644 --- a/src/components/CippComponents/CippInviteGuestDrawer.jsx +++ b/src/components/CippComponents/CippInviteGuestDrawer.jsx @@ -1,8 +1,8 @@ import { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Button } from "@mui/material"; import { Grid } from "@mui/system"; import { useForm, useFormState } from "react-hook-form"; -import { Send } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippApiResults } from "./CippApiResults"; @@ -85,9 +85,9 @@ export const CippInviteGuestDrawer = ({ return ( <> } + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippJitRoleTemplateApply.jsx b/src/components/CippComponents/CippJitRoleTemplateApply.jsx new file mode 100644 index 000000000000..cf9da5c43a1c --- /dev/null +++ b/src/components/CippComponents/CippJitRoleTemplateApply.jsx @@ -0,0 +1,70 @@ +import { useEffect, useRef } from "react"; +import { useWatch } from "react-hook-form"; +import CippFormComponent from "./CippFormComponent"; +import { useJitAllowedRoles } from "../../hooks/use-jit-allowed-roles"; + +/** + * A convenience selector that pulls the roles from one or more JIT Role Templates into a target roles + * field (e.g. adminRoles / defaultRoles). Roles are merged additively - existing selections are kept - + * and remain fully editable afterward. If the current user is restricted by an assigned JIT Role + * Template, roles outside their allow-list are dropped so the field never offers a role they cannot grant. + */ +export const CippJitRoleTemplateApply = ({ + formControl, + targetField, + name = "applyRoleTemplate", + label = "Apply JIT Role Template", +}) => { + const { restricted, allowedRoleIds } = useJitAllowedRoles(); + const selected = useWatch({ control: formControl.control, name }); + const lastApplied = useRef(null); + + useEffect(() => { + if (!selected || selected.length === 0) return; + const selectedKey = selected + .map((t) => t?.value) + .sort() + .join(","); + if (selectedKey === lastApplied.current) return; + lastApplied.current = selectedKey; + + const templateRoles = selected + .flatMap((t) => t?.addedFields?.roles || []) + .filter((r) => r && r.value) + .filter((r) => (restricted ? allowedRoleIds.includes(r.value) : true)) + .map((r) => ({ label: r.label, value: r.value })); + + const existing = formControl.getValues(targetField) || []; + const merged = [...existing]; + templateRoles.forEach((role) => { + if (!merged.some((m) => m.value === role.value)) { + merged.push(role); + } + }); + formControl.setValue(targetField, merged, { shouldValidate: true, shouldDirty: true }); + }, [selected]); + + return ( + + ); +}; + +export default CippJitRoleTemplateApply; diff --git a/src/components/CippComponents/CippJobProgress.jsx b/src/components/CippComponents/CippJobProgress.jsx new file mode 100644 index 000000000000..1d00ad9c6cb4 --- /dev/null +++ b/src/components/CippComponents/CippJobProgress.jsx @@ -0,0 +1,242 @@ +import { useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + Chip, + CircularProgress, + IconButton, + Stack, + Tooltip, + Typography, +} from '@mui/material' +import { CippCopyToClipBoard } from './CippCopyToClipboard' +import { CippApiDialog } from './CippApiDialog' +import { useDialog } from '../../hooks/use-dialog' +import { usePermissions } from '../../hooks/use-permissions' + +// Re-run actions for offboarding jobs. Both go through the offboarding endpoint, so they need the +// wizard's own permission rather than the scheduler's. Rows carry TaskId and TenantFilter. +export const OFFBOARDING_PROGRESS_ACTIONS = { + permissions: ['Identity.User.ReadWrite'], + rerun: { + url: '/api/ExecOffboardUser?Action=Rerun', + data: { TaskId: 'TaskId', tenantFilter: 'TenantFilter' }, + confirmText: + 'Queue every action for [Name] again? Actions that are not repeatable without effect, such as a password reset or a device wipe, will happen again.', + }, + rerunStep: { + url: '/api/ExecOffboardUser?Action=RerunStep', + data: { + TaskId: 'TaskId', + StepIndex: 'StepIndex', + StepTitle: 'StepTitle', + tenantFilter: 'TenantFilter', + }, + confirmText: 'Run "[StepTitle]" again for [Name]?', + }, +} + +const capitalize = (text) => + typeof text === 'string' && text.length > 0 + ? text.charAt(0).toUpperCase() + text.slice(1) + : text + +const STATUS_CHIP_COLORS = { + queued: 'default', + running: 'info', + succeeded: 'success', + failed: 'error', +} +const isDone = (status) => status === 'succeeded' || status === 'failed' + +// Plain-text rendering of the rows, for the copy buttons. +export const formatJobProgressText = (rows) => + rows + .map((row) => + [ + `${row.Tenant ?? row.Name}: ${capitalize(row.Status)}`, + ...(row.Steps || []).map( + (step) => + ` [${step.Status}] ${step.Title}` + + (step.Message + ? `\n ${String(step.Message).replace(/\n/g, '\n ')}` + : '') + ), + ].join('\n') + ) + .join('\n\n') + +const StepIcon = ({ status }) => { + if (status === 'succeeded') + return + if (status === 'failed') return + if (status === 'running') return + return +} + +// Live job progress: one collapsible block per row (a tenant, or a user for offboarding) with its +// steps. `actions` (see OFFBOARDING_PROGRESS_ACTIONS) adds Re-run for a whole row and for a single +// step on rows backed by a task (TaskId); onRerun lets the host resume polling afterwards. +export const CippJobProgress = ({ rows, onRerun, actions }) => { + const { checkPermissions } = usePermissions() + const canRerun = + !!actions && + !!onRerun && + (actions.permissions ? checkPermissions(actions.permissions) : true) + const dialog = useDialog() + const [pending, setPending] = useState(null) + const openRerun = (row, action) => { + setPending({ row, action }) + dialog.handleOpen() + } + + return ( + + {rows.map((row, rowIndex) => { + const steps = row.Steps || [] + const failed = steps.filter((step) => step.Status === 'failed').length + const done = isDone(row.Status) + const rerunnable = canRerun && !!row.TaskId + return ( + + }> + + + {row.Tenant ?? row.Name} + + {failed > 0 && ( + + {failed} of {steps.length} failed + + )} + e.stopPropagation()} + > + {rerunnable && actions.rerun && ( + + )} + + + + + + + + {steps.map((step, index) => ( + + + + + + {step.Title} + + {step.Message} + + + {rerunnable && + actions.rerunStep && + step.Kind !== 'notify' && ( + + + + openRerun( + { + ...row, + StepIndex: index, + StepTitle: step.Title, + }, + actions.rerunStep + ) + } + > + + + + + )} + + ))} + + + + ) + })} + {pending && ( + onRerun?.(), + }} + /> + )} + + ) +} + +export default CippJobProgress diff --git a/src/components/CippComponents/CippLibraryPermissionsDialog.jsx b/src/components/CippComponents/CippLibraryPermissionsDialog.jsx index fd3eb5bdc74c..fb9d39cd67ba 100644 --- a/src/components/CippComponents/CippLibraryPermissionsDialog.jsx +++ b/src/components/CippComponents/CippLibraryPermissionsDialog.jsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Alert, AlertTitle, @@ -10,7 +11,6 @@ import { Stack, Typography, } from '@mui/material' -import { Add, Delete, LinkOff, Link as LinkIcon, Tune } from '@mui/icons-material' import { useForm } from 'react-hook-form' import { CippDataTable } from '../CippTable/CippDataTable' import { CippApiDialog } from './CippApiDialog' @@ -132,7 +132,7 @@ export const CippLibraryPermissionsDialog = ({ { label: 'Change Permission Level', type: 'POST', - icon: , + icon: , url: '/api/ExecSetLibraryPermission', confirmText: `Set the permission level [Title] holds on ${scopeLabel}. Any other level they hold here is removed.${inheritanceWarning}`, condition: (assignment) => canWrite && !assignment.IsSystemManaged, @@ -165,7 +165,7 @@ export const CippLibraryPermissionsDialog = ({ { label: 'Remove Permission', type: 'POST', - icon: , + icon: , url: '/api/ExecRemoveLibraryPermission', confirmText: `Remove [PermissionLevel] from [Title] on ${scopeLabel}?${inheritanceWarning}`, color: 'error', @@ -218,7 +218,7 @@ export const CippLibraryPermissionsDialog = ({ size="small" variant="outlined" color="warning" - startIcon={} + startIcon={} disabled={!canWrite} onClick={resetDialog.handleOpen} > @@ -228,7 +228,7 @@ export const CippLibraryPermissionsDialog = ({ diff --git a/src/components/CippComponents/CippMailboxRestoreDrawer.jsx b/src/components/CippComponents/CippMailboxRestoreDrawer.jsx index e9816c0030f7..66d7eb5038dd 100644 --- a/src/components/CippComponents/CippMailboxRestoreDrawer.jsx +++ b/src/components/CippComponents/CippMailboxRestoreDrawer.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { useForm, useWatch, useFormState } from "react-hook-form"; import { Button, @@ -15,14 +16,6 @@ import { Tooltip, } from "@mui/material"; import { Grid } from "@mui/system"; -import { - Close as CloseIcon, - RestoreFromTrash, - DeleteForever, - Archive, - Storage, - AccountBox, -} from "@mui/icons-material"; import { useSettings } from "../../hooks/use-settings"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import CippFormComponent from "./CippFormComponent"; @@ -78,11 +71,8 @@ export const CippMailboxRestoreDrawer = ({ // Helper function to check if archive is active (GUID exists and is not all zeros) const hasActiveArchive = (mailbox) => { const archiveGuid = mailbox?.addedFields?.ArchiveGuid; - return ( - archiveGuid && - archiveGuid !== "00000000-0000-0000-0000-000000000000" && - archiveGuid.replace(/0/g, "").replace(/-/g, "") !== "" - ); + return (archiveGuid && + archiveGuid !== "00000000-0000-0000-0000-000000000000" && archiveGuid.replace(/0/g, "").replace(/-/g, "") !== ""); }; useEffect(() => { @@ -141,9 +131,9 @@ export const CippMailboxRestoreDrawer = ({ return ( <> } + startIcon={} onClick={() => setDrawerVisible(true)} - requiredPermissions={requiredPermissions} + {...(PermissionButton !== Button ? { requiredPermissions } : {})} > {buttonText} @@ -152,8 +142,10 @@ export const CippMailboxRestoreDrawer = ({ anchor="right" open={drawerVisible} onClose={handleCloseDrawer} - PaperProps={{ - sx: { width: { xs: "100%", sm: 600, md: 800 } }, + slotProps={{ + paper: { + sx: { width: { xs: "100%", sm: 600, md: 800 } }, + } }} > @@ -162,7 +154,7 @@ export const CippMailboxRestoreDrawer = ({ > New Mailbox Restore - + @@ -220,7 +212,7 @@ export const CippMailboxRestoreDrawer = ({ title={`Mailbox type: ${sourceMailbox.addedFields.recipientTypeDetails}`} > } + icon={} label={sourceMailbox.addedFields.recipientTypeDetails} size="small" color="info" @@ -236,7 +228,7 @@ export const CippMailboxRestoreDrawer = ({ } > } + icon={} label={ hasActiveArchive(sourceMailbox) ? "Archive Active" @@ -292,7 +284,7 @@ export const CippMailboxRestoreDrawer = ({ title={`Target mailbox type: ${targetMailbox.addedFields.recipientTypeDetails}`} > } + icon={} label={targetMailbox.addedFields.recipientTypeDetails} size="small" color="info" @@ -308,7 +300,7 @@ export const CippMailboxRestoreDrawer = ({ } > } + icon={} label={ hasActiveArchive(targetMailbox) ? "Archive Active" @@ -324,7 +316,7 @@ export const CippMailboxRestoreDrawer = ({ title={`Current mailbox size: ${targetMailbox.addedFields.TotalItemSize}`} > } + icon={} label={targetMailbox.addedFields.TotalItemSize} size="small" color="info" @@ -525,7 +517,7 @@ export const CippMailboxRestoreDrawer = ({ onClick={handleSubmit} disabled={!isValid || !isDirty || createRestore.isPending} startIcon={ - createRestore.isPending ? : + createRestore.isPending ? : } > {createRestore.isPending ? "Creating..." : "Create Restore Job"} diff --git a/src/components/CippComponents/CippMaintenanceBanner.jsx b/src/components/CippComponents/CippMaintenanceBanner.jsx index 77e30d24228d..5ce6bd711ee5 100644 --- a/src/components/CippComponents/CippMaintenanceBanner.jsx +++ b/src/components/CippComponents/CippMaintenanceBanner.jsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { CippIcons } from '../../utils/icon-registry' import NextLink from 'next/link' import { Box, Button, Chip, IconButton, Link, Stack, Typography, useMediaQuery } from '@mui/material' import { alpha, useTheme } from '@mui/material/styles' -import { Close, ErrorOutline, InfoOutlined, WarningAmber } from '@mui/icons-material' import { formatDistanceStrict } from 'date-fns' // Dismissal is keyed on the notice id so that re-issuing an edited notice brings the banner back @@ -13,9 +13,9 @@ const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day, matching FailedPayment const SEVERITIES = ['info', 'warning', 'error'] const ICONS = { - info: InfoOutlined, - warning: WarningAmber, - error: ErrorOutline, + info: CippIcons.InfoOutlined, + warning: CippIcons.WarningAmber, + error: CippIcons.ErrorOutlined, } // localStorage throws in locked-down browsers - never let that break the layout. @@ -221,20 +221,31 @@ export const CippMaintenanceBanner = ({ alert }) => { borderBottom: `1px solid ${solid ? alpha('#000000', 0.18) : alpha(palette.main, 0.42)}`, boxShadow: solid ? 'none' : `inset 0 3px 0 0 ${palette.main}`, px: { xs: 2, md: 3 }, - py: 1.25, + // Extend into the status-bar region under black-translucent / notched devices; + // the measured --cipp-banner-h then carries the inset for chrome below. + pt: 'calc(10px + env(safe-area-inset-top, 0px))', + pb: 1.25, }} > {/* Top-aligned so the icon and actions sit level with the title when the message wraps. */} - + - - + sx={{ + alignItems: { xs: 'flex-start', md: 'baseline' }, + flexGrow: 1, + minWidth: 0, + flexWrap: 'wrap' + }}> + {alert.title} @@ -297,7 +308,13 @@ export const CippMaintenanceBanner = ({ alert }) => { )} - + {link && ( {navigationPosition?.total > 0 && ( - + {navigationPosition.index} of {navigationPosition.total} )} @@ -292,11 +294,13 @@ export const CippPolicyImportDrawer = ({ label="Search Policies" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - InputProps={{ - startAdornment: , - }} placeholder="Search by policy name or description..." sx={{ mb: 2 }} + slotProps={{ + input: { + startAdornment: , + } + }} /> @@ -308,7 +312,13 @@ export const CippPolicyImportDrawer = ({ <> {[...Array(3)].map((_, index) => ( - + @@ -322,7 +332,13 @@ export const CippPolicyImportDrawer = ({ if (!policy) return null return ( - + - ) + ); } diff --git a/src/components/CippComponents/CippQuarantineDetails.jsx b/src/components/CippComponents/CippQuarantineDetails.jsx index 1e39e0e91fdb..598c0d6f4995 100644 --- a/src/components/CippComponents/CippQuarantineDetails.jsx +++ b/src/components/CippComponents/CippQuarantineDetails.jsx @@ -3,6 +3,7 @@ import { AccordionDetails, AccordionSummary, Chip, + Link, Stack, Table, TableBody, @@ -11,7 +12,8 @@ import { TableRow, Typography, } from '@mui/material' -import { ExpandMore } from '@mui/icons-material' +import { CippIcons } from '../../utils/icon-registry' +import NextLink from 'next/link' import { CippPropertyList } from './CippPropertyList' import { CippCopyToClipBoard } from './CippCopyToClipboard' import { getCippFormatting } from '../../utils/get-cipp-formatting' @@ -95,7 +97,7 @@ const Section = ({ defaultExpanded={defaultExpanded} sx={{ '&:before': { display: 'none' } }} > - }> + }> {title} @@ -146,8 +148,14 @@ export const CippQuarantineDetails = ({ row }) => { entry.recipientEmailAddress?.toLowerCase() === recipient?.toLowerCase() ) ?? details.data?.Results?.[0] const isEnriching = isEmail && details.isFetching - const enrichmentUnavailable = isEmail && details.isSuccess && !analyzed - const headerFallback = isEmail && details.data?.Metadata?.Source === 'Headers' + // A missing SecurityAnalyzedMessage.Read.All grant, not a licence gap - wins over the + // licence captions below so the operator is pointed at a fix they can apply themselves + const permissionError = + isEmail && details.data?.Metadata?.PermissionError === true + const enrichmentUnavailable = + isEmail && details.isSuccess && !analyzed && !permissionError + const headerFallback = + isEmail && details.data?.Metadata?.Source === 'Headers' && !permissionError const quarantineFields = [ { label: 'Received', value: row.ReceivedTime, field: 'ReceivedTime' }, @@ -276,7 +284,9 @@ export const CippQuarantineDetails = ({ row }) => { {row.Subject} - + {hasValue(row.Type) && ( { /> )} + {permissionError && ( + + Extended threat details require the SecurityAnalyzedMessage.Read.All + permission, which has not been granted yet. Run the{' '} + + Permission Check + {' '} + and use Repair Permissions to add it. + + )} {enrichmentUnavailable && ( - + Extended threat details are unavailable for this message (requires Microsoft Defender for Office 365). )} {headerFallback && ( - + Showing details parsed from the message headers and message contents. Microsoft per-URL and per-attachment threat verdicts require Microsoft Defender for Office 365 Plan 2. @@ -418,7 +444,7 @@ export const CippQuarantineDetails = ({ row }) => { )} - ) + ); } export default CippQuarantineDetails diff --git a/src/components/CippComponents/CippQuarantineTable.jsx b/src/components/CippComponents/CippQuarantineTable.jsx index 0f59f695fda9..b200417b0a81 100644 --- a/src/components/CippComponents/CippQuarantineTable.jsx +++ b/src/components/CippComponents/CippQuarantineTable.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { CircularProgress, Dialog, @@ -8,17 +9,6 @@ import { Skeleton, Typography, } from '@mui/material' -import { Block, Close, Done, DoneAll } from '@mui/icons-material' -import { - ArrowDownTrayIcon, - ArrowTopRightOnSquareIcon, - CodeBracketIcon, - DocumentTextIcon, - EyeIcon, - FlagIcon, - NoSymbolIcon, - TrashIcon, -} from '@heroicons/react/24/outline' import { CippTablePage } from './CippTablePage.jsx' import { CippMessageViewer } from './CippMessageViewer.jsx' import { CippQuarantineDetails } from './CippQuarantineDetails.jsx' @@ -101,8 +91,19 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { const [traceDetails, setTraceDetails] = useState([]) const [traceMessageId, setTraceMessageId] = useState(null) const [traceTenant, setTraceTenant] = useState(null) + const [traceWindow, setTraceWindow] = useState(null) const [messageSubject, setMessageSubject] = useState(null) + // Graph's message trace window caps at 10 days and defaults to ~48h with none supplied, + // so pin an explicit +/-1 day window around the message's received time when we have one. + const getTraceWindow = (receivedTime) => { + if (!receivedTime) return null + const receivedMs = new Date(receivedTime).getTime() + if (Number.isNaN(receivedMs)) return null + const receivedSeconds = Math.floor(receivedMs / 1000) + return { startDate: receivedSeconds - 86400, endDate: receivedSeconds + 86400 } + } + const messageTenant = resolveTenant(messageRow) const getMessageContents = ApiGetCall({ url: '/api/ListMailQuarantineMessage', @@ -140,7 +141,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { urlFromData: true, queryKey: `MessageTraceDetail-${traceTenant}-${traceMessageId}`, onResult: (result) => { - setTraceDetails(result) + setTraceDetails(result?.Results ?? result) }, }) @@ -168,13 +169,16 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { const viewMessageTrace = (...args) => { const row = resolveRow(...args) const rowTenant = resolveTenant(row) + const window = getTraceWindow(row.ReceivedTime) setTraceTenant(rowTenant) setTraceMessageId(row.MessageId) + setTraceWindow(window) getMessageTraceDetails.mutate({ url: '/api/ListMessageTrace', data: { tenantFilter: rowTenant, messageId: row.MessageId, + ...(window ?? {}), }, }) setMessageSubject(row.Subject) @@ -253,7 +257,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { Type: '!Release', }, confirmText: 'Are you sure you want to release this message?', - icon: , + icon: , condition: (row) => row.ReleaseStatus !== 'RELEASED', }, ...(isEmail @@ -273,7 +277,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { }, confirmText: 'Are you sure you want to release this email and add the sender to the whitelist?', - icon: , + icon: , condition: (row) => row.ReleaseStatus !== 'RELEASED', }, { @@ -288,7 +292,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { RecipientAddress: 'RecipientAddress', }, confirmText: 'Are you sure you want to deny this message?', - icon: , + icon: , condition: (row) => row.ReleaseStatus === 'REQUESTED', }, ] @@ -305,7 +309,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { }, confirmText: 'Are you sure you want to permanently delete this message from quarantine? This action cannot be undone.', - icon: , + icon: , color: 'danger', condition: (row) => row.ReleaseStatus !== 'RELEASED', }, @@ -315,28 +319,28 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { label: 'Preview Message', noConfirm: true, customFunction: viewMessage, - icon: , + icon: , hideBulk: true, }, { label: 'View Message Headers', noConfirm: true, customFunction: viewHeaders, - icon: , + icon: , hideBulk: true, }, { label: 'Download Message (.eml)', noConfirm: true, customFunction: downloadMessage, - icon: , + icon: , hideBulk: true, }, { label: 'View Message Trace', noConfirm: true, customFunction: viewMessageTrace, - icon: , + icon: , hideBulk: true, }, { @@ -368,7 +372,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { }, ], confirmText: 'Submit "[Subject]" to Microsoft for analysis?', - icon: , + icon: , hideBulk: true, }, { @@ -395,13 +399,13 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { ], confirmText: 'Block sender [SenderAddress] by adding an entry to the Tenant Allow/Block List?', - icon: , + icon: , }, { label: 'Open Email Entity in Defender', noConfirm: true, customFunction: openInDefender, - icon: , + icon: , hideBulk: true, }, ] @@ -458,7 +462,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { onClick={() => setDialogOpen(false)} sx={{ position: 'absolute', right: 8, top: 8 }} > - + @@ -484,7 +488,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { onClick={() => setHeaderDialogOpen(false)} sx={{ position: 'absolute', right: 8, top: 8 }} > - + @@ -518,7 +522,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { onClick={() => setTraceDialogOpen(false)} sx={{ position: 'absolute', right: 8, top: 8 }} > - + @@ -540,6 +544,7 @@ export const CippQuarantineTable = ({ entityType = 'Email' }) => { data: { tenantFilter: traceTenant, messageId: traceMessageId, + ...(traceWindow ?? {}), }, }) } diff --git a/src/components/CippComponents/CippQueryRefreshButton.jsx b/src/components/CippComponents/CippQueryRefreshButton.jsx index 313a07c607e5..93cee61a79f2 100644 --- a/src/components/CippComponents/CippQueryRefreshButton.jsx +++ b/src/components/CippComponents/CippQueryRefreshButton.jsx @@ -1,5 +1,5 @@ import { IconButton, SvgIcon, Tooltip } from '@mui/material' -import { ArrowPathIcon } from '@heroicons/react/24/outline' +import { CippIcons } from '../../utils/icon-registry' import { useQueryClient } from '@tanstack/react-query' /** @@ -37,7 +37,7 @@ export const CippQueryRefreshButton = ({ }, }} > - + diff --git a/src/components/CippComponents/CippReportDBControls.jsx b/src/components/CippComponents/CippReportDBControls.jsx index 4d0a71a4bcc9..7f599bb95d1e 100644 --- a/src/components/CippComponents/CippReportDBControls.jsx +++ b/src/components/CippComponents/CippReportDBControls.jsx @@ -1,7 +1,7 @@ import { useState, useMemo, useCallback } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Button, Chip, SvgIcon, Tooltip } from "@mui/material"; import { Stack } from "@mui/system"; -import { Sync, CloudDone, Bolt } from "@mui/icons-material"; import { useSettings } from "../../hooks/use-settings"; import { useDialog } from "../../hooks/use-dialog"; import { CippApiDialog } from "./CippApiDialog"; @@ -23,6 +23,8 @@ import { CippQueueTracker } from "../CippTable/CippQueueTracker"; * @param {string[]} [config.cacheColumns=["CacheTimestamp"]] - Extra columns to show when in cached mode. * @param {string} [config.tenantColumn="Tenant"] - Column name for tenant (shown in AllTenants mode). * @param {Object} [config.apiData] - Additional static API data to merge (e.g. extra params). + * @param {boolean} [config.serverPagination=false] - Server-side paging for cached reads; the + * endpoint must support manualPagination and the page must pass apiDataKey={reportDB.apiDataKey}. * * @returns {Object} * - useReportDB {boolean} - Current cache mode @@ -31,6 +33,7 @@ import { CippQueueTracker } from "../CippTable/CippQueueTracker"; * - resolvedApiUrl {string} - API URL with ?UseReportDB=true appended when needed * - resolvedApiData {Object|undefined} - Merged apiData (for pages that use apiData instead of URL params) * - resolvedQueryKey {string} - Query key including tenant and cache mode + * - apiDataKey {string|undefined} - Pass as apiDataKey when serverPagination is set ('Results' in cached mode) * - cacheColumns {string[]} - Columns to prepend/append when cached (includes Tenant for AllTenants) * - controls {JSX.Element} - Ready-to-render JSX for the cache toggle, sync button, and queue tracker * - syncDialog {JSX.Element} - The CippApiDialog element to render alongside CippTablePage @@ -49,6 +52,7 @@ export function useCippReportDB(config) { cacheColumns = ['CacheTimestamp'], tenantColumn = 'Tenant', apiData: extraApiData, + serverPagination = false, } = config const currentTenant = useSettings().currentTenant; @@ -85,12 +89,18 @@ export function useCippReportDB(config) { }, [apiUrl, useReportDB]) // Keep mode flag in the URL only; CippTablePage merges apiData into query params. + // serverPagination adds manualPagination on cached reads. const resolvedApiData = useMemo(() => { - if (!extraApiData) return undefined + const paging = serverPagination && useReportDB ? { manualPagination: true } : {} + if (!extraApiData && !serverPagination) return undefined return { + ...paging, ...extraApiData, } - }, [extraApiData]) + }, [extraApiData, serverPagination, useReportDB]) + + // Paged responses nest rows under Results; the legacy bare array needs no dataKey. + const apiDataKey = serverPagination && useReportDB ? 'Results' : undefined // Query key that includes tenant + mode for proper cache separation const resolvedQueryKey = useMemo(() => { @@ -130,14 +140,16 @@ export function useCippReportDB(config) { // The controls JSX const controls = ( - + {useReportDB && ( <> )} + {/* Not `disabled` when it cannot be toggled: this is a status badge, and MUI's disabled + fade drops the label to ~2:1. Not clickable and without onClick it is already inert. */} : } + icon={useReportDB ? : } label={useReportDB ? 'Cached' : 'Live'} color="primary" size="small" onClick={canToggle ? () => setUseReportDB((prev) => !prev) : undefined} clickable={canToggle} - disabled={!canToggle} variant="outlined" /> @@ -193,6 +206,7 @@ export function useCippReportDB(config) { resolvedApiUrl, resolvedApiData, resolvedQueryKey, + apiDataKey, cacheColumns: extraColumns, controls, syncDialog: syncDialogElement, diff --git a/src/components/CippComponents/CippReportToolbar.jsx b/src/components/CippComponents/CippReportToolbar.jsx index 9b635e592ca5..21212eafd642 100644 --- a/src/components/CippComponents/CippReportToolbar.jsx +++ b/src/components/CippComponents/CippReportToolbar.jsx @@ -10,6 +10,7 @@ import { Tooltip, Typography, } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' import { visuallyHidden } from '@mui/utils' import { useState, useEffect } from 'react' import { useRouter } from 'next/router' @@ -18,16 +19,6 @@ import { useSettings } from '../../hooks/use-settings' import { useIsMobileLayout } from '../../hooks/use-breakpoint' import { ApiGetCall } from '../../api/ApiCall.jsx' import { useQueryClient } from '@tanstack/react-query' -import { - Add, - Check, - Delete as DeleteIcon, - Edit, - KeyboardArrowDown, - MoreVert, - Refresh as RefreshIcon, - Sync, -} from '@mui/icons-material' import CippFormComponent from './CippFormComponent' import { CippAddTestReportDrawer } from './CippAddTestReportDrawer' import { CippApiDialog } from './CippApiDialog' @@ -139,7 +130,7 @@ export const CippReportToolbar = () => { {...(withRefreshAction && { customAction: { position: 'outside', - icon: , + icon: , tooltip: 'Refresh test suites', onClick: handleRefresh, }, @@ -179,14 +170,14 @@ export const CippReportToolbar = () => { switch test suite - + setActionSheetOpen(true)} sx={{ minWidth: 44, minHeight: 44 }} > - + ) : ( @@ -208,7 +199,7 @@ export const CippReportToolbar = () => { px: 2, }} onClick={openRefreshDialog} - startIcon={} + startIcon={} > Refresh @@ -238,7 +229,7 @@ export const CippReportToolbar = () => { variant="contained" color="error" disabled={isBuiltIn} - startIcon={} + startIcon={} sx={{ fontWeight: 'bold', textTransform: 'none', @@ -280,15 +271,17 @@ export const CippReportToolbar = () => { - {selected && } + slotProps={{ + primary: { noWrap: true }, + + secondary: { + noWrap: true, + variant: 'caption', + } + }} /> + {selected && } - ) + ); })} @@ -307,7 +300,7 @@ export const CippReportToolbar = () => { onClick={() => actionSheet.run(() => setCreateDrawerOpen(true))} > - + @@ -316,7 +309,7 @@ export const CippReportToolbar = () => { onClick={() => actionSheet.run(() => openRefreshDialog())} > - + @@ -326,7 +319,7 @@ export const CippReportToolbar = () => { onClick={() => actionSheet.run(() => setEditDrawerOpen(true))} > - + { onClick={() => actionSheet.run(() => openDeleteDialog())} > - + { onClick={() => actionSheet.run(() => handleRefresh())} > - + @@ -414,5 +407,5 @@ export const CippReportToolbar = () => { }} /> - ) + ); } diff --git a/src/components/CippComponents/CippRestoreBackupDrawer.jsx b/src/components/CippComponents/CippRestoreBackupDrawer.jsx index 30a5793f24c6..9e7b4f0c04b1 100644 --- a/src/components/CippComponents/CippRestoreBackupDrawer.jsx +++ b/src/components/CippComponents/CippRestoreBackupDrawer.jsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Button, Box, Typography, Alert, AlertTitle, Divider, Chip, Stack } from '@mui/material' import { Grid } from '@mui/system' import { useForm, useFormState } from 'react-hook-form' -import { SettingsBackupRestore } from '@mui/icons-material' import { CippOffCanvas } from './CippOffCanvas' import CippFormComponent from './CippFormComponent' import { CippFormCondition } from './CippFormCondition' @@ -159,7 +159,7 @@ export const CippRestoreBackupDrawer = ({ setDrawerVisible(true)} - startIcon={} + startIcon={} {...props} > {buttonText} @@ -380,5 +380,5 @@ export const CippRestoreBackupDrawer = ({ - ) + ); } diff --git a/src/components/CippComponents/CippRestoreWizard.jsx b/src/components/CippComponents/CippRestoreWizard.jsx index 6e919a5f8b92..53f63285ea32 100644 --- a/src/components/CippComponents/CippRestoreWizard.jsx +++ b/src/components/CippComponents/CippRestoreWizard.jsx @@ -23,19 +23,7 @@ import { Typography, useMediaQuery, } from "@mui/material"; -import { - CheckCircle, - DeselectOutlined, - DoneAll, - Error as ErrorIcon, - ExpandLess, - ExpandMore, - NavigateBefore, - NavigateNext, - SettingsBackupRestore, - UploadFile, - Warning, -} from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry" import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "./CippApiResults"; import { useState, useEffect, useMemo } from "react"; @@ -237,7 +225,7 @@ export const CippRestoreWizard = ({ variant="subtitle1" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }} > - + Backup Source }> + }> Validation Passed {validationResult.validRows} valid rows across {categories.length} categories. @@ -278,7 +266,9 @@ export const CippRestoreWizard = ({ {validationResult.warnings?.length > 0 && ( {validationResult.warnings.map((w, i) => ( - + ⚠ {w} ))} @@ -286,7 +276,7 @@ export const CippRestoreWizard = ({ )} ) : ( - }> + }> Validation Failed This backup cannot be restored safely. @@ -305,13 +295,15 @@ export const CippRestoreWizard = ({ const StepSelectCategories = () => ( - + {selectedCount} of {totalCount} categories selected ({filteredData.length} items) {!isLoading && step > 0 && !restoreAction.isSuccess && ( - )} {!isLoading && step < WIZARD_STEPS.length - 1 && ( ) : null} {showPermissions ? ( - ) : null} - + {showNew ? ( + + ) : null} {showEditSite ? ( - ) : null} + {onRefresh ? ( + + + + + + + + ) : null} - ) + ); } CippSharePointBrowserBanner.propTypes = { @@ -118,6 +466,14 @@ CippSharePointBrowserBanner.propTypes = { onStorageClick: PropTypes.func, showPermissions: PropTypes.bool, onPermissionsClick: PropTypes.func, + showNew: PropTypes.bool, showEditSite: PropTypes.bool, onEditSiteClick: PropTypes.func, + onRefresh: PropTypes.func, + refreshDisabled: PropTypes.bool, + path: PropTypes.array, + onNavigate: PropTypes.func, + siteOptions: PropTypes.array, + recentSites: PropTypes.array, + onSiteSwitch: PropTypes.func, } diff --git a/src/components/CippComponents/CippSharePointBrowserPermissions.jsx b/src/components/CippComponents/CippSharePointBrowserPermissions.jsx index ab3c86e7a381..d53c9ac64b5c 100644 --- a/src/components/CippComponents/CippSharePointBrowserPermissions.jsx +++ b/src/components/CippComponents/CippSharePointBrowserPermissions.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import PropTypes from 'prop-types' import { Alert, @@ -28,17 +29,6 @@ import { Tooltip, Typography, } from '@mui/material' -import { - Add, - Close, - DeleteOutline, - EditOutlined, - LinkOff, - Link as LinkIcon, - PersonSearch, - Refresh, - Security, -} from '@mui/icons-material' import { useForm } from 'react-hook-form' import { ApiGetCall } from '../../api/ApiCall' import { CippApiDialog } from './CippApiDialog' @@ -68,16 +58,21 @@ const SectionToolbar = ({ - + sx={{ + alignItems: "center", + justifyContent: "space-between", + mb: 1.5 + }}> + {title} {typeof count === 'number' ? : null} {actions.length ? ( - + {actions.map((action) => ( } + startIcon={action.icon ?? } onClick={action.onClick} disabled={action.disabled} > @@ -116,12 +111,14 @@ SectionToolbar.propTypes = { } const RowActions = ({ onEdit, onRemove, disableActions = true, disabledTitle = 'Coming soon' }) => ( - + {onEdit ? ( - + @@ -130,7 +127,7 @@ const RowActions = ({ onEdit, onRemove, disableActions = true, disabledTitle = ' - + @@ -146,7 +143,9 @@ RowActions.propTypes = { } const PrincipalChips = ({ row }) => ( - + {row.isGuest ? : null} {row.isSiteAdmin ? : null} {row.isSystemGroup ? : null} @@ -159,7 +158,12 @@ PrincipalChips.propTypes = { } const EmptyState = ({ message = 'None' }) => ( - + {message} ) @@ -221,7 +225,9 @@ const AccessTable = ({ rows = EMPTY, canWrite = false, systemGroupIds = EMPTY, o > - + {row.title || '—'} @@ -231,13 +237,15 @@ const AccessTable = ({ rows = EMPTY, canWrite = false, systemGroupIds = EMPTY, o {row.principalType || '—'} - + {levels.length ? levels.map((level) => ( } + icon={level.isSystemManaged ? undefined : } variant={level.isSystemManaged ? 'outlined' : 'filled'} label={level.name || '—'} title={ @@ -253,11 +261,12 @@ const AccessTable = ({ rows = EMPTY, canWrite = false, systemGroupIds = EMPTY, o + sx={{ + color: "text.secondary", + maxWidth: 220 + }}> {row.userPrincipalName || row.email || row.loginName || '—'} @@ -278,12 +287,12 @@ const AccessTable = ({ rows = EMPTY, canWrite = false, systemGroupIds = EMPTY, o /> - ) + ); })} - ) + ); } AccessTable.propTypes = { @@ -328,7 +337,9 @@ const MembersTable = ({ - + {row.title || '—'} @@ -340,11 +351,12 @@ const MembersTable = ({ + sx={{ + color: "text.secondary", + maxWidth: 240 + }}> {row.userPrincipalName || row.email || row.loginName || '—'} @@ -366,12 +378,12 @@ const MembersTable = ({ /> - ) + ); })} - ) + ); } MembersTable.propTypes = { @@ -407,7 +419,9 @@ const GraphSitePermissionsTable = ({ rows = EMPTY, canWrite = false, onRemove }) return ( - + {row.title || '—'} @@ -417,10 +431,12 @@ const GraphSitePermissionsTable = ({ rows = EMPTY, canWrite = false, onRemove }) - + {(row.roles ?? []).length ? row.roles.map((role) => ( - } label={role} /> + } label={role} /> )) : '—'} @@ -428,11 +444,14 @@ const GraphSitePermissionsTable = ({ rows = EMPTY, canWrite = false, onRemove }) + sx={{ + color: "text.secondary", + maxWidth: 260, + fontFamily: 'monospace', + fontSize: '0.75rem' + }}> {row.identityId || '—'} @@ -446,12 +465,12 @@ const GraphSitePermissionsTable = ({ rows = EMPTY, canWrite = false, onRemove }) /> - ) + ); })} - ) + ); } GraphSitePermissionsTable.propTypes = { @@ -555,7 +574,9 @@ const CheckAccessPanel = ({ return ( - + Pick a user to see every route that grants them access here — direct grants, SharePoint groups, nested Entra groups, tenant-wide claims, and (when cached) sharing links. This is the inverse of the Access tab: who can reach this place, and how. @@ -564,7 +585,9 @@ const CheckAccessPanel = ({ } > - This library has unique permissions. Restoring inheritance discards them and - follows the site again. + This library has broken inheritance (unique permissions). Fixing inheritance + discards them and follows the site again. ) : null} @@ -991,6 +1027,17 @@ export const CippSharePointBrowserPermissions = ({ } count={accessRows.length} actions={[ + ...(targetType === 'library' && hasUnique && !inherits + ? [ + { + label: 'Fix inheritance', + icon: , + onClick: () => restoreInheritanceDialog.handleOpen(), + disabled: !canWrite, + disabledTitle: writeDisabledTitle, + }, + ] + : []), { label: 'Grant user', onClick: () => grantUserDialog.handleOpen(), @@ -1051,9 +1098,10 @@ export const CippSharePointBrowserPermissions = ({ secondary={`${group.memberCount} member${group.memberCount === 1 ? '' : 's'}${ group.subtitle ? ` · ${group.subtitle}` : '' }`} - primaryTypographyProps={{ variant: 'body2', fontWeight: 500 }} - secondaryTypographyProps={{ variant: 'caption' }} - /> + slotProps={{ + primary: { variant: 'body2', fontWeight: 500 }, + secondary: { variant: 'caption' } + }} /> ))} @@ -1065,7 +1113,7 @@ export const CippSharePointBrowserPermissions = ({ count={activeGroup?.members?.length ?? 0} actions={[ { - label: 'Add user', + label: 'Add User', onClick: () => addUserDialog.handleOpen(), disabled: !canNestIntoActiveGroup, disabledTitle: !canWrite @@ -1075,7 +1123,7 @@ export const CippSharePointBrowserPermissions = ({ : 'Unavailable', }, { - label: 'Add group', + label: 'Add Group', onClick: () => addGroupDialog.handleOpen(), disabled: !canNestIntoActiveGroup, disabledTitle: !canWrite @@ -1089,9 +1137,11 @@ export const CippSharePointBrowserPermissions = ({ {activeGroup?.subtitle ? ( + sx={{ + color: "text.secondary", + display: 'block', + mb: 1 + }}> {activeGroup.subtitle} {activeGroup.kind === 'associated' ? ' · Associated group' : ''} @@ -1126,7 +1176,13 @@ export const CippSharePointBrowserPermissions = ({ disableRemoveTitle="Cannot remove the last site collection admin" onRemoveMember={(row) => removeAdminDialog.handleOpen(row)} /> - + Site collection admins are separate from Owners group membership. @@ -1141,7 +1197,13 @@ export const CippSharePointBrowserPermissions = ({ canWrite={canWrite} onRemove={(row) => removeGraphPermissionDialog.handleOpen(row)} /> - + Site-scoped Graph grants (typically Sites.Selected app access). Separate from SharePoint role assignments and sharing links. @@ -1570,7 +1632,12 @@ export const CippSharePointBrowserPermissions = ({ label="Keep the permissions it currently inherits" formControl={formHook} /> - + Turn this off to start from an empty permission set. Only site collection admins can reach the library until permissions are granted. @@ -1586,8 +1653,9 @@ export const CippSharePointBrowserPermissions = ({ - ) + ); } CippSharePointBrowserPermissions.propTypes = { diff --git a/src/components/CippComponents/CippSharePointBrowserProperties.jsx b/src/components/CippComponents/CippSharePointBrowserProperties.jsx index 128652107ff5..e52685097135 100644 --- a/src/components/CippComponents/CippSharePointBrowserProperties.jsx +++ b/src/components/CippComponents/CippSharePointBrowserProperties.jsx @@ -1,11 +1,94 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import PropTypes from 'prop-types' -import { Card, CardHeader, Typography } from '@mui/material' +import { Card, CardHeader, Chip, Skeleton, Stack, Tooltip, Typography } from '@mui/material' import { CippPropertyList } from './CippPropertyList' import { CippCopyToClipBoard } from './CippCopyToClipboard' -import { ApiPostCall } from '../../api/ApiCall' +import { LinearProgressWithLabel } from '../linearProgressWithLabel' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { usePermissions } from '../../hooks/use-permissions' -const isSiteLike = (item) => item && (item.type === 'site' || item.canOpen) +const isSiteLike = (item) => + item && (item.type === 'site' || (item.canOpen && item.type !== 'recycleFolder')) + +const formatBytes = (bytes) => { + const num = Number(bytes) + if (bytes === null || bytes === undefined || bytes === '' || Number.isNaN(num)) return null + if (num < 1024) return `${num} B` + const gb = num / (1024 * 1024 * 1024) + if (gb >= 0.01) return `${gb.toLocaleString(undefined, { maximumFractionDigits: 2 })} GB` + const mb = num / (1024 * 1024) + return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB` +} + +/** SPO admin quota is reported in MB — same roll-up as CippSharePointQuotaCard. */ +const formatQuotaMb = (sizeInMB) => { + const size = Number(sizeInMB) + if (!Number.isFinite(size)) return null + if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} TB` + if (size >= 1024) return `${(size / 1024).toFixed(2)} GB` + return `${Math.round(size)} MB` +} + +const toMbFromBytes = (bytes) => { + const num = Number(bytes) + if (!Number.isFinite(num) || num < 0) return null + return num / (1024 * 1024) +} + +const QuotaMeter = ({ usedMB, totalMB, usedTooltip, freeTooltip, caption, loading = false }) => { + if (loading) { + return + } + + const hasQuota = Number.isFinite(usedMB) && Number.isFinite(totalMB) && totalMB > 0 + if (!hasQuota) { + return ( + Unavailable + + ); + } + + const quotaPct = Math.min(100, Math.round((usedMB / totalMB) * 1000) / 10) + const freeMB = Math.max(0, totalMB - usedMB) + + return ( + + + + + + + + + + + {caption ? ( + + {caption} + + ) : null} + + ); +} + +QuotaMeter.propTypes = { + usedMB: PropTypes.number, + totalMB: PropTypes.number, + usedTooltip: PropTypes.string, + freeTooltip: PropTypes.string, + caption: PropTypes.node, + loading: PropTypes.bool, +} const formatVersionPolicy = (props) => { if (!props || typeof props !== 'object') return null @@ -44,19 +127,69 @@ const formatVersionPolicy = (props) => { return '—' } +const summarizeSites = (sites) => { + if (!Array.isArray(sites)) return null + + let storage = 0 + let files = 0 + let storageKnown = false + let filesKnown = false + const byType = {} + + for (const site of sites) { + const bytes = Number(site.storageUsedInBytes) + if (Number.isFinite(bytes)) { + storage += bytes + storageKnown = true + } + const fileCount = Number(site.fileCount) + if (Number.isFinite(fileCount)) { + files += fileCount + filesKnown = true + } + const type = site.siteType?.trim() || 'Unknown' + byType[type] = (byType[type] || 0) + 1 + } + + const typeEntries = Object.entries(byType).sort( + (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]) + ) + + return { + sites: sites.length, + storage: storageKnown ? storage : null, + files: filesKnown ? files : null, + typeEntries, + } +} + /** * Left-hand property panel for the selected SharePoint site or library. * List columns cover type / name / files / size — this pane keeps IDs, URL, and site version policy. + * At the root site list with nothing selected, shows a rollup of the loaded sites. */ export const CippSharePointBrowserProperties = ({ item, + summaryItems, tenantFilter, isFetching = false, emptyMessage = 'Select an item to view details.', }) => { + const { checkPermissions } = usePermissions() + const canReadQuota = checkPermissions(['Sharepoint.Admin.Read', 'Sharepoint.Admin.ReadWrite']) + const showSummary = !item && Array.isArray(summaryItems) + const quotaEnabled = + showSummary && Boolean(tenantFilter) && tenantFilter !== 'AllTenants' && canReadQuota + const siteUrl = isSiteLike(item) ? item.webUrl : null const siteId = isSiteLike(item) ? item.id : null const sitePropsApi = ApiPostCall({}) + const quotaApi = ApiGetCall({ + url: '/api/ListSharepointQuota', + data: { tenantFilter }, + queryKey: `${tenantFilter}-ListSharepointQuota`, + waiting: quotaEnabled, + }) useEffect(() => { if (!tenantFilter || (!siteUrl && !siteId)) return @@ -89,8 +222,135 @@ export const CippSharePointBrowserProperties = ({ (siteUrl || siteId) && (sitePropsApi.isPending || (!siteAdminProps && !sitePropsApi.isError)) ) + const rootSummary = useMemo( + () => (showSummary ? summarizeSites(summaryItems) : null), + [showSummary, summaryItems] + ) + const summaryLoading = Boolean( + showSummary && isFetching && summaryItems.length === 0 + ) + + const usedMB = Number(quotaApi.data?.GeoUsedStorageMB) + const totalMB = Number(quotaApi.data?.TenantStorageMB) + const hasQuota = Number.isFinite(usedMB) && Number.isFinite(totalMB) && totalMB > 0 + const quotaFetching = quotaEnabled && quotaApi.isFetching && !hasQuota + + const siteUsedFromProps = Number(siteAdminProps?.StorageUsage) + const siteQuotaFromProps = Number(siteAdminProps?.StorageMaximumLevel) + const siteWarningFromProps = Number(siteAdminProps?.StorageWarningLevel) + const siteUsedFromItem = toMbFromBytes(item?.storageUsedInBytes) + const siteUsedMB = Number.isFinite(siteUsedFromProps) + ? siteUsedFromProps + : siteUsedFromItem + const siteQuotaMB = Number.isFinite(siteQuotaFromProps) ? siteQuotaFromProps : null + const siteNearWarning = + Number.isFinite(siteWarningFromProps) && + Number.isFinite(siteUsedMB) && + siteUsedMB >= siteWarningFromProps + const siteQuotaLoading = Boolean( + isSiteLike(item) && versionsFetching && !Number.isFinite(siteQuotaMB) + ) + const propertyItems = (() => { - if (!item) return [] + if (!item) { + if (!rootSummary) return [] + const rows = [ + { + label: 'Sites', + value: rootSummary.sites.toLocaleString(), + }, + { + label: 'Listed storage', + value: formatBytes(rootSummary.storage) || '—', + }, + ] + + if (quotaEnabled) { + rows.push({ + label: 'Tenant quota', + value: ( + + ), + }) + } + + rows.push( + { + label: 'Total files', + value: + rootSummary.files !== null ? rootSummary.files.toLocaleString() : '—', + }, + { + label: 'By type', + value: rootSummary.typeEntries.length ? ( + + {rootSummary.typeEntries.map(([type, count], index) => ( + + ))} + + ) : ( + '—' + ), + } + ) + + return rows + } + + if (item.type === 'recycleFolder') { + return [ + { label: 'Type', value: 'Folder (recycle path)' }, + { + label: 'Path', + value: item.dirName ? : '—', + }, + ] + } + + if (item.type === 'recycleItem') { + return [ + { label: 'Type', value: item.siteType || '—' }, + { label: 'State', value: item.itemState || '—' }, + { + label: 'Size', + value: formatBytes(item.storageUsedInBytes) || '—', + }, + { label: 'Deleted by', value: item.deletedByName || '—' }, + { + label: 'Deleted', + value: item.createdDateTime + ? new Date(item.createdDateTime).toLocaleString() + : '—', + }, + { + label: 'Path', + value: item.dirName ? : '—', + }, + { + label: 'Item ID', + value: item.id ? : '—', + }, + ] + } if (isSiteLike(item)) { return [ @@ -98,9 +358,30 @@ export const CippSharePointBrowserProperties = ({ label: 'Description', value: item.description?.trim() ? item.description : '—', }, + { + label: 'Site quota', + value: ( + + ), + }, { label: 'Versions', - value: versionsFetching ? '' : versionsLabel || '—', + value: versionsFetching ? ( + + ) : ( + versionsLabel || '—' + ), }, { label: 'Site ID', @@ -138,46 +419,69 @@ export const CippSharePointBrowserProperties = ({ ] })() + const subheader = (() => { + if (!item) return rootSummary ? 'Overview' : 'Nothing selected' + if (item.type === 'recycleFolder') return 'Recycle folder' + if (item.type === 'recycleItem') return 'Deleted item' + if (item.type === 'site' || (item.canOpen && item.type !== 'recycleFolder')) return 'Site' + return 'Library' + })() + + const title = item?.displayName ?? item?.name ?? (rootSummary ? 'Sites' : 'Properties') + const showEmptyPrompt = !item && !Array.isArray(summaryItems) && !isFetching + return ( - {!item && !isFetching ? ( - + title={title} + subheader={subheader} + slotProps={{ + title: { variant: 'h6', noWrap: true }, + subheader: { variant: 'caption' } + }} /> + {showEmptyPrompt ? ( + {emptyMessage} ) : ( )} - ) + ); } CippSharePointBrowserProperties.propTypes = { item: PropTypes.object, + summaryItems: PropTypes.array, tenantFilter: PropTypes.string, isFetching: PropTypes.bool, emptyMessage: PropTypes.string, diff --git a/src/components/CippComponents/CippSharePointBrowserRowActions.jsx b/src/components/CippComponents/CippSharePointBrowserRowActions.jsx new file mode 100644 index 000000000000..9be27ce6ddf7 --- /dev/null +++ b/src/components/CippComponents/CippSharePointBrowserRowActions.jsx @@ -0,0 +1,137 @@ +import { useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' +import PropTypes from 'prop-types' +import { + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, +} from '@mui/material' + +/** + * Filter SharePoint browser row actions by optional per-item condition. + */ +export function filterSharePointBrowserRowActions(actions, item) { + return (actions ?? []).filter((action) => { + if (typeof action.condition === 'function') return action.condition(item) + return true + }) +} + +const ActionMenuItems = ({ actions, item, onPick }) => + actions.map((action) => ( + { + onPick?.() + if (!action.href) { + action.onClick?.(item) + } + }} + component={action.href ? 'a' : 'li'} + href={action.href ? action.href(item) : undefined} + target={action.href ? '_blank' : undefined} + rel={action.href ? 'noopener noreferrer' : undefined} + > + {action.icon ? {action.icon} : null} + {action.label} + + )) + +ActionMenuItems.propTypes = { + actions: PropTypes.array.isRequired, + item: PropTypes.object.isRequired, + onPick: PropTypes.func, +} + +/** + * Row ⋮ menu for the SharePoint site browser FolderView. + */ +export const CippSharePointBrowserRowActions = ({ item, actions = [] }) => { + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const available = filterSharePointBrowserRowActions(actions, item) + + if (!available.length) return null + + return ( + <> + { + event.stopPropagation() + setAnchorEl(event.currentTarget) + }} + > + + + setAnchorEl(null)} + onClick={(event) => event.stopPropagation()} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + slotProps={{ paper: { sx: { minWidth: 200 } } }} + > + setAnchorEl(null)} + /> + + + ) +} + +CippSharePointBrowserRowActions.propTypes = { + item: PropTypes.object.isRequired, + actions: PropTypes.array, +} + +/** + * Cursor-positioned context menu — same action list as the row ⋮ menu. + * Mirrors CippDataTable right-click behavior for the SharePoint explorer. + */ +export const CippSharePointBrowserContextMenu = ({ + open = false, + position = null, + item = null, + actions = [], + onClose, +}) => { + const available = item ? filterSharePointBrowserRowActions(actions, item) : [] + const menuOpen = Boolean(open && position && item && available.length > 0) + + return ( + + {item && available.length ? ( + + ) : null} + + ) +} + +CippSharePointBrowserContextMenu.propTypes = { + open: PropTypes.bool, + position: PropTypes.shape({ + mouseX: PropTypes.number, + mouseY: PropTypes.number, + }), + item: PropTypes.object, + actions: PropTypes.array, + onClose: PropTypes.func, +} diff --git a/src/components/CippComponents/CippSharePointBrowserStorage.jsx b/src/components/CippComponents/CippSharePointBrowserStorage.jsx index a054dd438c64..983f76233cb0 100644 --- a/src/components/CippComponents/CippSharePointBrowserStorage.jsx +++ b/src/components/CippComponents/CippSharePointBrowserStorage.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import PropTypes from 'prop-types' import { Alert, @@ -24,17 +25,8 @@ import { Tooltip, Typography, } from '@mui/material' -import { - CleaningServices, - Close, - Refresh, - RestoreFromTrash, - Storage as StorageIcon, -} from '@mui/icons-material' -import { CippDataTable } from '../CippTable/CippDataTable' import { CippApiDialog } from './CippApiDialog' -import CippFormComponent from './CippFormComponent' -import { CippFormCondition } from './CippFormCondition' +import { CippSharePointVersionCleanupFields, formatVersionPolicy } from './CippSharePointVersionCleanupFields' import { CippPropertyList } from './CippPropertyList' import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' import { useDialog } from '../../hooks/use-dialog' @@ -90,40 +82,6 @@ const toBytesFromMb = (mb) => { return num * 1024 * 1024 } -const formatVersionPolicy = (props) => { - if (!props || typeof props !== 'object') return null - if (props.InheritVersionPolicyFromTenant) return 'Tenant default' - const major = - props.MajorVersionLimit === null || props.MajorVersionLimit === undefined - ? null - : Number(props.MajorVersionLimit) - const days = - props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined - ? null - : Number(props.ExpireVersionsAfterDays) - - if (props.EnableAutoExpirationVersionTrim) { - const parts = ['Auto trim'] - if (major !== null && !Number.isNaN(major) && major > 0) { - parts.push(`${major.toLocaleString()} major`) - } - if (days !== null && !Number.isNaN(days) && days > 0) { - parts.push(`${days.toLocaleString()} days`) - } - return parts.join(' · ') - } - - if (major !== null && !Number.isNaN(major)) { - if (major <= 0) return 'Unlimited / not set' - const label = `${major.toLocaleString()} major versions` - if (days !== null && !Number.isNaN(days) && days > 0) { - return `${label} · expire after ${days.toLocaleString()} days` - } - return label - } - return null -} - const jobStatusChip = (progress) => { if (!progress || typeof progress === 'string') { return { label: 'No job', color: 'default' } @@ -145,86 +103,21 @@ const jobStatusChip = (progress) => { return { label: progress.Status, color: 'info' } } -const VersionCleanupFields = ({ formHook }) => ( - <> - - - - - - - - - -) - -VersionCleanupFields.propTypes = { - formHook: PropTypes.object.isRequired, -} - /** - * Site-scoped Storage sheet for cleanup. - * Overview (cheap live): used/quota, version policy, top libraries. - * Recycle / Versions tabs: cleanup actions — no file-level scans. + * Site capacity triage sheet (not the tenant Storage Report). + * Overview: used/quota, unexplained gap vs libraries, top libraries. + * Versions: cleanup job. Recycle lives in the site explorer; tenant analytics on Storage Report. */ export const CippSharePointBrowserStorage = ({ open = false, onClose, item, tenantFilter, + focusLibraryId = null, }) => { const [tab, setTab] = useState(0) const { checkPermissions } = usePermissions() const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite']) - const canReadRecycleBin = checkPermissions([ - 'Sharepoint.SiteRecycleBin.Read', - 'Sharepoint.SiteRecycleBin.ReadWrite', - ]) - const canRestore = checkPermissions(['Sharepoint.SiteRecycleBin.ReadWrite']) const startCleanupDialog = useDialog() const siteUrl = item?.webUrl @@ -274,7 +167,7 @@ export const CippSharePointBrowserStorage = ({ const refreshAll = () => { fetchSiteProps() librariesApi.refetch?.() - if (tab === 2) fetchJobStatus() + if (tab === 1) fetchJobStatus() } useEffect(() => { @@ -285,7 +178,7 @@ export const CippSharePointBrowserStorage = ({ }, [open, siteUrl, siteId, tenant]) useEffect(() => { - if (!open || tab !== 2) return + if (!open || tab !== 1) return fetchJobStatus() // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, tab, siteUrl, siteId, tenant]) @@ -345,6 +238,14 @@ export const CippSharePointBrowserStorage = ({ ) const librariesMeasuredLabel = formatBytes(librariesMeasuredBytes) const maxLibBytes = topLibraries[0]?._bytes > 0 ? topLibraries[0]._bytes : 0 + const gapBytes = + usedBytes != null && librariesMeasuredBytes >= 0 + ? Math.max(0, usedBytes - librariesMeasuredBytes) + : null + const gapLabel = formatBytes(gapBytes) + const stormanHref = siteUrl + ? `${String(siteUrl).replace(/\/+$/, '')}/_layouts/15/storman.aspx` + : null const glanceLoading = sitePropsApi.isPending && !siteProps const libsLoading = librariesApi.isFetching && !libraryRows.length @@ -354,32 +255,12 @@ export const CippSharePointBrowserStorage = ({ onClose?.() } - const recycleBinQueryKey = `SiteBrowserRecycleBin-${siteUrl}` - - const recycleActions = [ - { - label: 'Restore Item', - type: 'POST', - icon: , - url: '/api/ExecRestoreRecycleBinItems', - data: { - Ids: 'Id', - ItemNames: 'LeafName', - SiteUrl: siteUrl, - tenantFilter: tenant, - }, - confirmText: 'Restore [LeafName] from the recycle bin?', - condition: () => canRestore, - multiPost: false, - }, - ] - return ( <> - Storage — {siteName} + Site capacity — {siteName} @@ -389,16 +270,16 @@ export const CippSharePointBrowserStorage = ({ onClick={refreshAll} disabled={!siteUrl || glanceLoading} > - + - + - + {!siteUrl ? ( No site URL available for this selection. ) : ( @@ -410,7 +291,6 @@ export const CippSharePointBrowserStorage = ({ allowScrollButtonsMobile > - @@ -427,12 +307,13 @@ export const CippSharePointBrowserStorage = ({ direction="row" spacing={0.75} useFlexGap - flexWrap="wrap" - alignItems="center" - > + sx={{ + flexWrap: "wrap", + alignItems: "center" + }}> } + icon={} color={nearWarning ? 'warning' : 'default'} label={ quotaLabel @@ -454,6 +335,14 @@ export const CippSharePointBrowserStorage = ({ label={`Libraries ${librariesMeasuredLabel}`} /> ) : null} + {gapLabel && gapBytes > 0 ? ( + + ) : null} {quotaBytes ? ( @@ -464,19 +353,44 @@ export const CippSharePointBrowserStorage = ({ color={quotaBarColor} sx={{ height: 8, borderRadius: 1 }} /> - + {nearWarning - ? 'Near quota warning — reclaim recycle or trim versions before the site locks writes.' + ? 'Near quota warning — reclaim recycle (explorer Recycle mode) or trim versions before the site locks writes.' : 'Quota usage from site properties (live).'} ) : null} - - Cleanup path: check largest libraries → Recycle bin - (1st/2nd stage) → Versions if history looks like the gap. Version bytes are - not measured live (that would scan files). - + {gapBytes != null && gapBytes > 0 ? ( + + Site used is about {gapLabel} higher than measured root libraries — often + recycle bin, file versions, or other lists. Use Recycle in the explorer or + Versions below; open classic Storage Metrics for folder-level truth. + + ) : ( + + Site capacity triage for this site. Recycle is in the explorer Recycle mode. + + )} + + + {stormanHref ? ( + + ) : null} + )} @@ -484,19 +398,22 @@ export const CippSharePointBrowserStorage = ({ + sx={{ + alignItems: "center", + justifyContent: "space-between", + mb: 1 + }}> Largest libraries {libsLoading ? : null} {librariesApi.isError ? ( - Could not load library sizes. You can still use Recycle and Versions. + Could not load library sizes. You can still use Versions cleanup. ) : !libsLoading && !topLibraries.length ? ( - + No document libraries returned for this site. ) : ( @@ -520,27 +437,48 @@ export const CippSharePointBrowserStorage = ({ maxLibBytes > 0 && !Number.isNaN(lib._bytes) && lib._bytes > 0 ? Math.min(100, (lib._bytes / maxLibBytes) * 100) : 0 + const focused = focusLibraryId && lib.id === focusLibraryId return ( - + + theme.palette.mode === 'dark' + ? 'action.selected' + : 'warning.light', + } + : undefined + } + > {lib.displayName || lib.name || '—'} + {focused ? ' (selected)' : ''} - + {lib.siteType || '—'} - + {lib.fileCount != null ? Number(lib.fileCount).toLocaleString() : '—'} - + {formatBytes(lib.storageUsedInBytes) || '—'} @@ -554,7 +492,7 @@ export const CippSharePointBrowserStorage = ({ - ) + ); })} @@ -562,9 +500,11 @@ export const CippSharePointBrowserStorage = ({ )} + sx={{ + color: "text.secondary", + display: 'block', + mt: 0.75 + }}> Library size = root folder StorageMetrics (live). Site used may be higher — recycle, versions, and other lists are not in this table. {libraryRows.length > TOP_LIBRARIES @@ -576,52 +516,17 @@ export const CippSharePointBrowserStorage = ({ - {!canReadRecycleBin ? ( - - Recycle bin requires SharePoint recycle bin read permission. - - ) : ( - <> - - First and second stage together (newest first, capped by the API). Filter on - Item State. Sizes are per item — totals are not fully summed live. - - - - )} - - - - + sx={{ + alignItems: "center", + justifyContent: "space-between", + mb: 1.5 + }}> + Version history trim @@ -629,7 +534,7 @@ export const CippSharePointBrowserStorage = ({ @@ -704,10 +609,15 @@ export const CippSharePointBrowserStorage = ({ - {({ formHook }) => } + {({ formHook }) => ( + + )} - ) + ); } CippSharePointBrowserStorage.propTypes = { @@ -744,4 +660,5 @@ CippSharePointBrowserStorage.propTypes = { onClose: PropTypes.func, item: PropTypes.object, tenantFilter: PropTypes.string, + focusLibraryId: PropTypes.string, } diff --git a/src/components/CippComponents/CippSharePointFolderView.jsx b/src/components/CippComponents/CippSharePointFolderView.jsx index 5c02606a3eb9..e5963af29937 100644 --- a/src/components/CippComponents/CippSharePointFolderView.jsx +++ b/src/components/CippComponents/CippSharePointFolderView.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import PropTypes from 'prop-types' import { Alert, @@ -15,10 +16,6 @@ import { IconButton, InputAdornment, Link, - ListItemIcon, - ListItemText, - Menu, - MenuItem, Popover, Radio, RadioGroup, @@ -35,17 +32,12 @@ import { Typography, } from '@mui/material' import { alpha } from '@mui/material/styles' +import { hasTextSelection } from '../CippTable/util-row-text-interaction' import { - ArrowUpward, - Clear, - FilterList, - Folder, - FolderOpen, - FolderShared, - MoreVert, - OpenInNew, - Search as SearchIcon, -} from '@mui/icons-material' + CippSharePointBrowserContextMenu, + CippSharePointBrowserRowActions, + filterSharePointBrowserRowActions, +} from './CippSharePointBrowserRowActions' const formatDate = (value) => { if (!value) return '—' @@ -86,61 +78,10 @@ const formatSizeMbTooltip = (bytes) => { return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB` } -const RowActionsMenu = ({ item, actions = [] }) => { - const [anchorEl, setAnchorEl] = useState(null) - const open = Boolean(anchorEl) - const available = actions.filter((action) => { - if (typeof action.condition === 'function') return action.condition(item) - return true - }) - - if (!available.length) return null - - return ( - <> - { - event.stopPropagation() - setAnchorEl(event.currentTarget) - }} - > - - - setAnchorEl(null)} - onClick={(event) => event.stopPropagation()} - anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} - transformOrigin={{ horizontal: 'right', vertical: 'top' }} - > - {available.map((action) => ( - { - setAnchorEl(null) - action.onClick?.(item) - }} - component={action.href ? 'a' : 'li'} - href={action.href?.(item)} - target={action.href ? '_blank' : undefined} - rel={action.href ? 'noopener noreferrer' : undefined} - > - {action.icon ? {action.icon} : null} - {action.label} - - ))} - - +const isRowClickTarget = (event) => + event.target?.closest?.( + 'button, a, input, textarea, select, [role="button"], [role="menuitem"], [data-no-row-click="true"]' ) -} - -RowActionsMenu.propTypes = { - item: PropTypes.object.isRequired, - actions: PropTypes.array, -} const formatFileCount = (value) => { if (value === null || value === undefined || value === '') return '—' @@ -149,7 +90,7 @@ const formatFileCount = (value) => { return num.toLocaleString() } -const COLUMNS = [ +const BROWSE_COLUMNS = [ { id: 'name', label: 'Name', align: 'left', width: undefined, defaultDir: 'asc' }, { id: 'webUrl', label: 'URL', align: 'center', width: 72, defaultDir: 'asc' }, { id: 'siteType', label: 'Type', align: 'left', width: '14%', defaultDir: 'asc' }, @@ -158,6 +99,25 @@ const COLUMNS = [ { id: 'created', label: 'Created', align: 'left', width: '16%', defaultDir: 'desc' }, ] +const RECYCLE_COLUMNS = [ + { id: 'name', label: 'Name', align: 'left', width: undefined, defaultDir: 'asc' }, + { id: 'siteType', label: 'Type', align: 'left', width: '12%', defaultDir: 'asc' }, + { id: 'size', label: 'Size', align: 'right', width: '10%', defaultDir: 'desc' }, + { id: 'deletedBy', label: 'Deleted by', align: 'left', width: '14%', defaultDir: 'asc' }, + { id: 'created', label: 'Deleted', align: 'left', width: '16%', defaultDir: 'desc' }, + { id: 'itemState', label: 'State', align: 'left', width: '12%', defaultDir: 'asc' }, +] + +const RECYCLE_LIST_COLUMNS = [ + { id: 'name', label: 'Name', align: 'left', width: undefined, defaultDir: 'asc' }, + { id: 'relativePath', label: 'Path', align: 'left', width: '22%', defaultDir: 'asc' }, + { id: 'siteType', label: 'Type', align: 'left', width: '10%', defaultDir: 'asc' }, + { id: 'size', label: 'Size', align: 'right', width: '8%', defaultDir: 'desc' }, + { id: 'deletedBy', label: 'Deleted by', align: 'left', width: '12%', defaultDir: 'asc' }, + { id: 'created', label: 'Deleted', align: 'left', width: '14%', defaultDir: 'desc' }, + { id: 'itemState', label: 'State', align: 'left', width: '10%', defaultDir: 'asc' }, +] + const getSortValue = (item, columnId) => { switch (columnId) { case 'name': @@ -178,6 +138,12 @@ const getSortValue = (item, columnId) => { const time = item.createdDateTime ? Date.parse(item.createdDateTime) : NaN return Number.isFinite(time) ? time : null } + case 'deletedBy': + return (item.deletedByName ?? '').toString().toLocaleLowerCase() + case 'itemState': + return (item.itemState ?? '').toString().toLocaleLowerCase() + case 'relativePath': + return (item.relativePath ?? '').toString().toLocaleLowerCase() default: return null } @@ -204,7 +170,18 @@ const compareItems = (a, b, columnId, direction) => { } const itemSearchText = (item) => - [item?.displayName, item?.name, item?.webUrl, item?.siteType, item?.type] + [ + item?.displayName, + item?.name, + item?.webUrl, + item?.siteType, + item?.type, + item?.deletedByName, + item?.itemState, + item?.dirName, + item?.leafName, + item?.relativePath, + ] .filter(Boolean) .join(' ') .toLowerCase() @@ -224,6 +201,15 @@ const SIZE_FILTERS = [ { label: 'Over 100 GB', value: 100 * GB }, ] +const RECYCLE_STAGE_FILTERS = [ + { label: 'All stages', value: 'all', chipLabel: null }, + { label: 'First stage', value: 'first', chipLabel: '1st stage' }, + { label: 'Second stage', value: 'second', chipLabel: '2nd stage' }, +] + +const recycleStageChipLabel = (stage) => + RECYCLE_STAGE_FILTERS.find((option) => option.value === stage)?.chipLabel ?? null + const typeLabel = (item) => { const label = (item?.siteType ?? '').toString().trim() return label || 'Unknown' @@ -243,7 +229,8 @@ const sizeFilterLabel = (minSizeBytes) => /** * Explorer-style details list for the SharePoint site browser. - * Columns: Name, URL, Type, Files, Size (GB), Created. + * Browse columns: Name, URL, Type, Files, Size (GB), Created. + * Recycle columns: Name, Type, Size, Deleted by, Deleted, State. * Click selects; double-click / Enter opens when canOpen is true. */ export const CippSharePointFolderView = ({ @@ -258,13 +245,28 @@ export const CippSharePointFolderView = ({ onOpen, rowActions = [], emptyMessage = 'No items found.', + mode = 'browse', + modeSwitch = null, + infoMessage = null, + recycleView = 'folders', + recycleStage = 'all', + onRecycleStageChange, + showBreadcrumbs = true, }) => { + const isRecycle = mode === 'recycle' + const isRecycleList = isRecycle && recycleView === 'list' + const columns = isRecycle + ? isRecycleList + ? RECYCLE_LIST_COLUMNS + : RECYCLE_COLUMNS + : BROWSE_COLUMNS const [sortBy, setSortBy] = useState('name') const [sortDir, setSortDir] = useState('asc') const [searchQuery, setSearchQuery] = useState('') const [filterTypes, setFilterTypes] = useState([]) const [minSizeBytes, setMinSizeBytes] = useState(0) const [filterAnchor, setFilterAnchor] = useState(null) + const [rowContextMenu, setRowContextMenu] = useState(null) const pathKey = path.map((crumb) => crumb?.id ?? crumb?.webUrl ?? '').join('/') useEffect(() => { @@ -272,7 +274,10 @@ export const CippSharePointFolderView = ({ setFilterTypes([]) setMinSizeBytes(0) setFilterAnchor(null) - }, [pathKey]) + setSortBy('name') + setSortDir('asc') + setRowContextMenu(null) + }, [pathKey, mode, recycleView]) const handleCrumbClick = (index) => { if (!onNavigate) return @@ -283,14 +288,14 @@ export const CippSharePointFolderView = ({ } } - const canGoUp = path.length > 0 + const canGoUp = path.length > 0 && typeof onNavigate === 'function' const handleGoUp = () => { if (!canGoUp || !onNavigate) return onNavigate(path.slice(0, -1)) } const handleSort = (columnId) => { - const column = COLUMNS.find((col) => col.id === columnId) + const column = columns.find((col) => col.id === columnId) if (!column) return if (sortBy === columnId) { setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')) @@ -311,8 +316,13 @@ export const CippSharePointFolderView = ({ .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })) }, [items]) - const filtersActive = filterTypes.length > 0 || minSizeBytes > 0 - const activeFilterCount = filterTypes.length + (minSizeBytes > 0 ? 1 : 0) + const stageFilterActive = isRecycle && recycleStage !== 'all' + const filtersActive = + filterTypes.length > 0 || (!isRecycle && minSizeBytes > 0) || stageFilterActive + const activeFilterCount = + filterTypes.length + + (!isRecycle && minSizeBytes > 0 ? 1 : 0) + + (stageFilterActive ? 1 : 0) const filteredItems = useMemo( () => @@ -335,11 +345,24 @@ export const CippSharePointFolderView = ({ const searchActive = searchQuery.trim().length > 0 const noMatches = (searchActive || filtersActive) && items.length > 0 && sortedItems.length === 0 - const searchPlaceholder = canGoUp ? 'Search libraries…' : 'Search sites…' + const searchPlaceholder = isRecycle + ? 'Search deleted items…' + : canGoUp + ? 'Search libraries…' + : 'Search sites…' + const columnCount = columns.length + 2 // checkbox + actions const clearFilters = () => { setFilterTypes([]) setMinSizeBytes(0) + if (isRecycle && recycleStage !== 'all') { + onRecycleStageChange?.('all') + } + } + + const handleRecycleStageChange = (next) => { + if (!next || next === recycleStage) return + onRecycleStageChange?.(next) } const toggleType = (label) => { @@ -383,522 +406,760 @@ export const CippSharePointFolderView = ({ onSelect?.(item) } + const handleRowContextMenu = (event, item) => { + if (!rowActions?.length) return + if (isRowClickTarget(event) || hasTextSelection()) return + const available = filterSharePointBrowserRowActions(rowActions, item) + if (!available.length) return + event.preventDefault() + event.stopPropagation() + // Focus the row for properties / bulk context (explorer-style). + onCheckedChange?.([item.id]) + onSelect?.(item) + setRowContextMenu({ + mouseX: event.clientX + 2, + mouseY: event.clientY - 6, + item, + }) + } + const showTable = !isFetching && (canGoUp || items.length > 0) return ( - - - - - handleCrumbClick(-1)} - sx={{ cursor: 'pointer' }} - > - Sites - - {path.map((crumb, index) => { - const isLast = index === path.length - 1 - if (isLast) { - return ( - - {crumb.displayName ?? crumb.name} - - ) - } - return ( - handleCrumbClick(index)} - sx={{ cursor: 'pointer' }} - > - {crumb.displayName ?? crumb.name} - - ) - })} - + <> + + - setSearchQuery(event.target.value)} - placeholder={searchPlaceholder} - aria-label={searchPlaceholder} - disabled={isFetching} + direction={{ xs: 'column', md: 'row' }} + spacing={1.5} + sx={{ + alignItems: { md: 'center' }, + justifyContent: "space-between" + }}> + - - - ), - endAdornment: searchQuery ? ( - - setSearchQuery('')} - edge="end" - sx={{ p: 0.5 }} - > - - - - ) : null, - }} - /> - - + + setFilterAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + slotProps={{ paper: { sx: { width: 300, p: 2 } } }} > - Filters - - - setFilterAnchor(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} - slotProps={{ paper: { sx: { width: 300, p: 2 } } }} - > - - - Filters - - + + + Filters + + - - - Type - - {availableTypes.length === 0 ? ( - - No types in this list. + + + Type - ) : ( - - {availableTypes.map(({ label, count }) => ( - toggleType(label)} - /> - } - label={ - - {label}{' '} - - ({count}) + {availableTypes.length === 0 ? ( + + No types in this list. + + ) : ( + + {availableTypes.map(({ label, count }) => ( + toggleType(label)} + /> + } + label={ + + {label}{' '} + + ({count}) + - - } - sx={{ mr: 0, ml: 0 }} - /> - ))} - - )} - + } + sx={{ mr: 0, ml: 0 }} + /> + ))} + + )} + - + - - - Minimum size - - setMinSizeBytes(Number(event.target.value))} - > - {SIZE_FILTERS.map((option) => ( - } - label={{option.label}} - sx={{ mr: 0, ml: 0 }} - /> - ))} - - - - + {isRecycle ? ( + + + Recycle stage + + handleRecycleStageChange(event.target.value)} + > + {RECYCLE_STAGE_FILTERS.map((option) => ( + } + label={{option.label}} + sx={{ mr: 0, ml: 0 }} + /> + ))} + + + ) : ( + + + Minimum size + + setMinSizeBytes(Number(event.target.value))} + > + {SIZE_FILTERS.map((option) => ( + } + label={{option.label}} + sx={{ mr: 0, ml: 0 }} + /> + ))} + + + )} + + + - - {filtersActive ? ( - - {filterTypes.map((label) => ( - toggleType(label)} - /> - ))} - {minSizeBytes > 0 ? ( - setMinSizeBytes(0)} - /> - ) : null} - - - ) : null} - - {error ? ( - {typeof error === 'string' ? error : 'Failed to load items.'} - ) : null} - - {isFetching ? ( - - - - ) : !showTable ? ( - - {emptyMessage} - - ) : ( - - + {infoMessage} + + ) : null} + + {filtersActive ? ( + - theme.palette.mode === 'dark' - ? theme.palette.background.default - : alpha(theme.palette.neutral[200], 0.4), - backgroundImage: 'none', - }, - }} - > - - - - - - {COLUMNS.map((column) => ( - - handleSort(column.id)} - sx={ - column.align === 'right' - ? { flexDirection: 'row-reverse', ml: 'auto' } - : column.align === 'center' - ? { mx: 'auto' } - : undefined - } - > - {column.label} - - - ))} - - - - - {canGoUp ? ( - { - if (event.key === 'Enter') handleGoUp() - }} - sx={{ cursor: 'pointer' }} - > - - - - - - .. - - - Go up - - - - - - — - - - - - — - - - - - — - - - - - — - - - - - — - - - - - ) : null} - {noMatches ? ( - - - - {searchActive && filtersActive - ? `No matches for “${searchQuery.trim()}” with the current filters.` - : searchActive - ? `No matches for “${searchQuery.trim()}”.` - : 'No items match the current filters.'} - - - - ) : null} - {sortedItems.length === 0 && canGoUp && !searchActive && !filtersActive ? ( + flexWrap: "wrap", + alignItems: "center" + }}> + {filterTypes.map((label) => ( + toggleType(label)} + /> + ))} + {minSizeBytes > 0 && !isRecycle ? ( + setMinSizeBytes(0)} + /> + ) : null} + {stageFilterActive ? ( + handleRecycleStageChange('all')} + /> + ) : null} + + + ) : null} + + {error ? ( + {typeof error === 'string' ? error : 'Failed to load items.'} + ) : null} + + {isFetching ? ( + + + + ) : !showTable ? ( + + {emptyMessage} + + ) : ( + +
+ theme.palette.mode === 'dark' + ? theme.palette.background.default + : alpha(theme.palette.neutral[200], 0.4), + backgroundImage: 'none', + }, + }} + > + - - - {emptyMessage} - + + + {columns.map((column) => ( + + handleSort(column.id)} + sx={ + column.align === 'right' + ? { flexDirection: 'row-reverse', ml: 'auto' } + : column.align === 'center' + ? { mx: 'auto' } + : undefined + } + > + {column.label} + + + ))} + - ) : null} - {sortedItems.map((item) => { - const checked = checkedIdSet.has(item.id) - const isSite = - item.type === 'site' || item.canOpen - const Icon = isSite ? (checked ? FolderOpen : Folder) : FolderShared - - return ( + + + {canGoUp ? ( handleRowActivate(event, item)} - onDoubleClick={() => { - if (item.canOpen) onOpen?.(item) - }} + onClick={handleGoUp} + onDoubleClick={handleGoUp} onKeyDown={(event) => { - if (event.key === 'Enter') { - if (item.canOpen) onOpen?.(item) - else handleRowActivate(event, item) - } - }} - sx={{ - cursor: 'pointer', - borderLeft: (theme) => - checked - ? `3px solid ${theme.palette.warning.main}` - : '3px solid transparent', - '&.Mui-selected': { - bgcolor: (theme) => - alpha( - theme.palette.warning.main, - theme.palette.mode === 'dark' ? 0.22 : 0.14 - ), - }, - '&.Mui-selected:hover': { - bgcolor: (theme) => - alpha( - theme.palette.warning.main, - theme.palette.mode === 'dark' ? 0.3 : 0.2 - ), - }, + if (event.key === 'Enter') handleGoUp() }} + sx={{ cursor: 'pointer' }} > - { - event.stopPropagation() - handleToggleOne(item.id) - }} - > - handleToggleOne(item.id)} - onClick={(event) => event.stopPropagation()} - color="warning" - inputProps={{ - 'aria-label': `Select ${item.displayName ?? item.name ?? 'item'}`, - }} - /> - + - - + - - {item.displayName ?? item.name} + + .. + + + Go up - event.stopPropagation()}> - {item.webUrl ? ( - - - - - - ) : ( - + {columns.slice(1).map((column) => ( + + - )} - - - - {item.siteType || '—'} + + ))} + + + ) : null} + {noMatches ? ( + + + + {searchActive && filtersActive + ? `No matches for “${searchQuery.trim()}” with the current filters.` + : searchActive + ? `No matches for “${searchQuery.trim()}”.` + : 'No items match the current filters.'} - - - {formatFileCount(item.fileCount)} + + ) : null} + {sortedItems.length === 0 && canGoUp && !searchActive && !filtersActive ? ( + + + + {emptyMessage} - - {formatSizeMbTooltip(item.storageUsedInBytes) ? ( - - - {formatSizeGbLabel(item.storageUsedInBytes)} + + ) : null} + {sortedItems.map((item) => { + const checked = checkedIdSet.has(item.id) + const isFolderish = + item.type === 'site' || + item.type === 'recycleFolder' || + (item.canOpen && item.type !== 'recycleItem') + const Icon = isFolderish + ? checked + ? CippIcons.FolderOpen + : CippIcons.Folder + : isRecycle + ? item.siteType === 'Folder' + ? CippIcons.FolderShared + : item.siteType === 'List' || item.siteType === 'List Item' + ? CippIcons.Description + : CippIcons.InsertDriveFile + : CippIcons.FolderShared + + return ( + handleRowActivate(event, item)} + onContextMenu={(event) => handleRowContextMenu(event, item)} + onDoubleClick={() => { + if (item.canOpen) onOpen?.(item) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + if (item.canOpen) onOpen?.(item) + else handleRowActivate(event, item) + } + }} + sx={{ + cursor: 'pointer', + borderLeft: (theme) => + checked + ? `3px solid ${theme.palette.warning.main}` + : '3px solid transparent', + '&.Mui-selected': { + bgcolor: (theme) => + alpha( + theme.palette.warning.main, + theme.palette.mode === 'dark' ? 0.22 : 0.14 + ), + }, + '&.Mui-selected:hover': { + bgcolor: (theme) => + alpha( + theme.palette.warning.main, + theme.palette.mode === 'dark' ? 0.3 : 0.2 + ), + }, + }} + > + { + event.stopPropagation() + handleToggleOne(item.id) + }} + > + handleToggleOne(item.id)} + onClick={(event) => event.stopPropagation()} + color="warning" + slotProps={{ + input: { + 'aria-label': `Select ${item.displayName ?? item.name ?? 'item'}`, + } + }} + /> + + + + + + {item.displayName ?? item.name} - + + + {isRecycle ? ( + <> + {isRecycleList ? ( + + + {item.relativePath || '—'} + + + ) : null} + + + {item.siteType || '—'} + + + + {item.type === 'recycleFolder' && !item.canRestore ? ( + + — + + ) : formatSizeMbTooltip(item.storageUsedInBytes) ? ( + + + {formatSizeGbLabel(item.storageUsedInBytes)} + + + ) : ( + + — + + )} + + + + {item.type === 'recycleFolder' && !item.canRestore + ? '—' + : item.deletedByName || '—'} + + + + + {item.type === 'recycleFolder' && !item.canRestore + ? '—' + : formatDate(item.createdDateTime)} + + + + + {item.type === 'recycleFolder' && !item.canRestore + ? '—' + : item.itemState || '—'} + + + ) : ( - - — - + <> + event.stopPropagation()}> + {item.webUrl ? ( + + + + + + ) : ( + + — + + )} + + + + {item.siteType || '—'} + + + + + {formatFileCount(item.fileCount)} + + + + {formatSizeMbTooltip(item.storageUsedInBytes) ? ( + + + {formatSizeGbLabel(item.storageUsedInBytes)} + + + ) : ( + + — + + )} + + + + {formatDate(item.createdDateTime)} + + + )} - - - - {formatDate(item.createdDateTime)} - - - event.stopPropagation()} - > - - - - ) - })} - -
-
- )} -
-
- ) + event.stopPropagation()} + > + + +
+ ); + })} + + + + )} + + + setRowContextMenu(null)} + /> + + ); } CippSharePointFolderView.propTypes = { @@ -915,4 +1176,11 @@ CippSharePointFolderView.propTypes = { onOpen: PropTypes.func, rowActions: PropTypes.array, emptyMessage: PropTypes.string, + mode: PropTypes.oneOf(['browse', 'recycle']), + modeSwitch: PropTypes.node, + infoMessage: PropTypes.node, + recycleView: PropTypes.oneOf(['folders', 'list']), + recycleStage: PropTypes.oneOf(['all', 'first', 'second']), + onRecycleStageChange: PropTypes.func, + showBreadcrumbs: PropTypes.bool, } diff --git a/src/components/CippComponents/CippSharePointLibraryCopyDialog.jsx b/src/components/CippComponents/CippSharePointLibraryCopyDialog.jsx new file mode 100644 index 000000000000..fcc2c09c6dee --- /dev/null +++ b/src/components/CippComponents/CippSharePointLibraryCopyDialog.jsx @@ -0,0 +1,802 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' +import PropTypes from 'prop-types' +import axios from 'axios' +import { + Alert, + Box, + Button, + Card, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + LinearProgress, + List, + ListItemButton, + ListItemText, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material' +import { Grid } from '@mui/system' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { usePermissions } from '../../hooks/use-permissions' +import { buildVersionedHeaders } from '../../utils/cippVersion' +import { filterEligibleCopyLibraries } from '../../utils/sharepoint-library-copy-eligible' + +const POLL_MS = 20000 + +const LIBRARY_COPY_STATUS = { + Processing: { label: 'In progress', alertSeverity: 'info', progressColor: 'primary', chipColor: 'info' }, + Completed: { label: 'Completed', alertSeverity: 'success', progressColor: 'success', chipColor: 'success' }, + CompletedWithErrors: { + label: 'Completed with errors', + alertSeverity: 'warning', + progressColor: 'warning', + chipColor: 'warning', + }, + Failed: { label: 'Failed', alertSeverity: 'error', progressColor: 'error', chipColor: 'error' }, +} + +const formatCopyBytes = (bytes) => { + const num = Number(bytes) + if (bytes === null || bytes === undefined || bytes === '' || Number.isNaN(num)) return null + if (num === 0) return '0 B' + if (num < 1024) return `${num} B` + const gb = num / (1024 * 1024 * 1024) + if (gb >= 0.01) return `${gb.toLocaleString(undefined, { maximumFractionDigits: 2 })} GB` + const mb = num / (1024 * 1024) + return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB` +} + +const formatMetric = (value) => { + if (value === null || value === undefined || value === '') return '—' + return Number(value).toLocaleString() +} + +const formatLastUpdated = (iso) => { + if (!iso) return null + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleString() +} + +const LibraryCopyStatusPanel = ({ status, operationId, refreshing, onRefresh }) => { + const statusKey = status?.Status ?? 'Processing' + const statusMeta = LIBRARY_COPY_STATUS[statusKey] ?? LIBRARY_COPY_STATUS.Processing + const isTerminal = ['Completed', 'CompletedWithErrors', 'Failed'].includes(statusKey) + const progressValue = + status?.ProgressPercent != null ? Math.min(100, Math.max(0, Number(status.ProgressPercent))) : null + + const objectsLabel = + status?.TotalExpectedObjects != null + ? `${formatMetric(status.ObjectsProcessed)} / ${formatMetric(status.TotalExpectedObjects)}` + : formatMetric(status?.ObjectsProcessed) + + const metrics = [ + { label: 'Jobs complete', value: `${formatMetric(status?.JobsComplete)} / ${formatMetric(status?.JobsTotal)}` }, + { label: 'Objects processed', value: objectsLabel }, + { label: 'Files created', value: formatMetric(status?.FilesCreated) }, + { label: 'Data copied', value: formatCopyBytes(status?.BytesProcessed) ?? '—' }, + { + label: 'Errors', + value: formatMetric(status?.TotalErrors), + emphasis: (status?.TotalErrors ?? 0) > 0 ? 'error.main' : undefined, + }, + { + label: 'Warnings', + value: formatMetric(status?.TotalWarnings), + emphasis: (status?.TotalWarnings ?? 0) > 0 ? 'warning.main' : undefined, + }, + ] + + return ( + + + + ) : statusKey === 'Failed' ? ( + + ) : isTerminal ? ( + + ) : undefined + } + label={statusMeta.label} + /> + + + + + {status?.Message ? ( + {status.Message} + ) : ( + Waiting for the first status update… + )} + + {(status?.Errors?.length ?? 0) > 0 ? ( + + + Error details + + + {status.Errors.map((entry, index) => ( + + {entry.Message} + + ))} + + + Paths and filenames are redacted from SharePoint logs. + + + ) : null} + + {(status?.Warnings?.length ?? 0) > 0 ? ( + + + Warnings + + + {status.Warnings.map((entry, index) => ( + + {entry.Message} + + ))} + + + ) : null} + + + + + + Source + + {status?.SourceSiteName ?? '—'} + + {status?.SourceLibraryName ?? '—'} + + + + + + + + Destination + + {status?.DestSiteName ?? '—'} + + {status?.DestLibraryName ?? '—'} + + + + + + {progressValue != null ? ( + + + + Overall progress + + + {progressValue}% + + + + + ) : ( + !isTerminal && + )} + + {status ? ( + + + {metrics.map((metric) => ( + + + {metric.label} + + + {metric.value} + + + ))} + + + ) : null} + + + + Operation ID:{' '} + + {operationId} + + + {status?.LastUpdatedUtc ? ( + + Last updated: {formatLastUpdated(status.LastUpdatedUtc)} + + ) : null} + + + ); +} + +LibraryCopyStatusPanel.propTypes = { + status: PropTypes.object, + operationId: PropTypes.string.isRequired, + refreshing: PropTypes.bool, + onRefresh: PropTypes.func.isRequired, +} + +const LibraryCopyPane = ({ + label, + tenantFilter, + initialSite, + initialLibrary, + selectedSite, + selectedLibrary, + onSelectSite, + onSelectLibrary, + onClearLibrary, + otherSiteId, + otherListId, +}) => { + const [view, setView] = useState(initialSite && initialLibrary ? 'libraries' : 'sites') + + useEffect(() => { + if (initialSite && initialLibrary && !selectedSite) { + onSelectSite(initialSite) + onSelectLibrary(initialLibrary) + setView('libraries') + } + }, [initialSite, initialLibrary, onSelectLibrary, onSelectSite, selectedSite]) + + const sitesApi = ApiGetCall({ + url: '/api/ListSiteBrowser', + data: { tenantFilter }, + queryKey: `LibraryCopySites-${tenantFilter}-${label}`, + waiting: !!tenantFilter, + }) + + const librariesApi = ApiGetCall({ + url: '/api/ListSiteLibraries', + data: { + tenantFilter, + SiteId: selectedSite?.id, + SiteUrl: selectedSite?.webUrl, + }, + queryKey: `LibraryCopyLibs-${tenantFilter}-${label}-${selectedSite?.id ?? 'none'}`, + waiting: !!tenantFilter && view === 'libraries' && !!selectedSite?.id, + }) + + const sites = useMemo(() => { + const raw = sitesApi.data?.Results + return Array.isArray(raw) ? raw.filter((row) => row.type === 'site') : [] + }, [sitesApi.data]) + + const libraries = useMemo(() => { + const raw = librariesApi.data?.Results + return filterEligibleCopyLibraries(Array.isArray(raw) ? raw : []) + }, [librariesApi.data]) + + const isBlockedLibrary = (lib) => + selectedSite?.id === otherSiteId && lib.Id === otherListId + + return ( + + + {label} + + {view === 'libraries' && selectedSite ? ( + <> + + + + {selectedSite.displayName ?? selectedSite.name} + + + {librariesApi.isFetching ? ( + + Loading libraries… + + ) : ( + + {libraries.map((lib) => { + const blocked = isBlockedLibrary(lib) + const selected = selectedLibrary?.Id === lib.Id + return ( + { + if (blocked) return + onSelectLibrary({ + id: lib.Id, + Id: lib.Id, + displayName: lib.Title, + template: lib.Template, + }) + }} + > + + + ) + })} + + )} + + ) : ( + <> + {sitesApi.isFetching ? ( + + Loading sites… + + ) : ( + + {sites.map((site) => ( + { + onSelectSite(site) + onClearLibrary() + setView('libraries') + }} + > + + + ))} + + )} + + )} + + ); +} + +LibraryCopyPane.propTypes = { + label: PropTypes.string.isRequired, + tenantFilter: PropTypes.string, + initialSite: PropTypes.object, + initialLibrary: PropTypes.object, + selectedSite: PropTypes.object, + selectedLibrary: PropTypes.object, + onSelectSite: PropTypes.func.isRequired, + onSelectLibrary: PropTypes.func.isRequired, + onClearLibrary: PropTypes.func.isRequired, + otherSiteId: PropTypes.string, + otherListId: PropTypes.string, +} + +export const CippSharePointLibraryCopyDialog = ({ + open, + onClose, + tenantFilter, + sourceSite, + sourceLibrary, +}) => { + const { checkPermissions } = usePermissions() + const canWrite = checkPermissions(['Sharepoint.Site.ReadWrite']) + + const [sourceSelectedSite, setSourceSelectedSite] = useState(null) + const [sourceSelectedLibrary, setSourceSelectedLibrary] = useState(null) + const [destSelectedSite, setDestSelectedSite] = useState(null) + const [destSelectedLibrary, setDestSelectedLibrary] = useState(null) + const [conflictBehavior, setConflictBehavior] = useState('Replace') + const [confirmOpen, setConfirmOpen] = useState(false) + const [preflight, setPreflight] = useState(null) + const [operationId, setOperationId] = useState(null) + const [status, setStatus] = useState(null) + const [error, setError] = useState(null) + const [statusRefreshing, setStatusRefreshing] = useState(false) + + const execApi = ApiPostCall({}) + const pollTimerRef = useRef(null) + const pollInFlightRef = useRef(false) + const pollStoppedRef = useRef(false) + + const resetSession = useCallback(() => { + setConfirmOpen(false) + setPreflight(null) + setOperationId(null) + setStatus(null) + setError(null) + }, []) + + useEffect(() => { + if (!open) { + resetSession() + return + } + if (sourceSite) setSourceSelectedSite(sourceSite) + if (sourceLibrary) { + setSourceSelectedLibrary({ + id: sourceLibrary.id, + Id: sourceLibrary.id, + displayName: sourceLibrary.displayName, + template: sourceLibrary.template, + }) + } + }, [open, resetSession, sourceLibrary, sourceSite]) + + const selectionPayload = useMemo( + () => ({ + tenantFilter, + SourceSiteId: sourceSelectedSite?.id, + SourceSiteUrl: sourceSelectedSite?.webUrl, + SourceListId: sourceSelectedLibrary?.Id ?? sourceSelectedLibrary?.id, + SourceSiteName: sourceSelectedSite?.displayName, + SourceLibraryName: sourceSelectedLibrary?.displayName, + DestSiteId: destSelectedSite?.id, + DestSiteUrl: destSelectedSite?.webUrl, + DestListId: destSelectedLibrary?.Id ?? destSelectedLibrary?.id, + DestSiteName: destSelectedSite?.displayName, + DestLibraryName: destSelectedLibrary?.displayName, + NameConflictBehavior: conflictBehavior, + }), + [ + conflictBehavior, + destSelectedLibrary, + destSelectedSite, + sourceSelectedLibrary, + sourceSelectedSite, + tenantFilter, + ] + ) + + const readyForReview = + !!selectionPayload.SourceListId && + !!selectionPayload.DestListId && + !!selectionPayload.SourceSiteId && + !!selectionPayload.DestSiteId + + const handleReview = async () => { + setError(null) + try { + const res = await execApi.mutateAsync({ + url: '/api/ExecSiteBrowserLibraryCopy', + data: { ...selectionPayload, Action: 'PreflightLibraryCopy' }, + }) + const result = res?.data?.Results + if (typeof result === 'string') { + setError(result) + return + } + setPreflight(result) + setConfirmOpen(true) + } catch (e) { + setError(e?.message ?? 'Preflight failed.') + } + } + + const handleStart = async () => { + setError(null) + setConfirmOpen(false) + try { + const res = await execApi.mutateAsync({ + url: '/api/ExecSiteBrowserLibraryCopy', + data: { ...selectionPayload, Action: 'StartLibraryCopy' }, + }) + const result = res?.data?.Results + if (typeof result === 'string') { + setError(result) + return + } + setOperationId(result.OperationId) + } catch (e) { + setError(e?.message ?? 'Failed to start copy.') + } + } + + const clearPollTimer = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current) + pollTimerRef.current = null + } + }, []) + + const stopPolling = useCallback(() => { + pollStoppedRef.current = true + clearPollTimer() + }, [clearPollTimer]) + + const fetchStatus = useCallback( + async ({ manual = false } = {}) => { + if (!operationId || !tenantFilter) return + if (!manual && pollStoppedRef.current) return + if (pollInFlightRef.current) return + + pollInFlightRef.current = true + if (manual) setStatusRefreshing(true) + + try { + const response = await axios.post( + '/api/ListSiteBrowserLibraryCopy', + { tenantFilter, OperationId: operationId }, + { headers: await buildVersionedHeaders() }, + ) + if (!manual && pollStoppedRef.current) return + + const result = response?.data?.Results + if (typeof result === 'string') { + setError(result) + if (!manual) stopPolling() + return + } + + setError(null) + setStatus(result) + if (['Completed', 'CompletedWithErrors', 'Failed'].includes(result?.Status)) { + stopPolling() + } + } catch (e) { + if (manual || !pollStoppedRef.current) { + setError(e?.message ?? 'Status poll failed.') + if (!manual) stopPolling() + } + } finally { + pollInFlightRef.current = false + if (manual) setStatusRefreshing(false) + } + }, + [operationId, stopPolling, tenantFilter], + ) + + useEffect(() => { + if (!operationId || !tenantFilter) return undefined + + pollStoppedRef.current = false + pollInFlightRef.current = false + + fetchStatus() + pollTimerRef.current = setInterval(() => fetchStatus(), POLL_MS) + + return () => stopPolling() + }, [fetchStatus, operationId, stopPolling, tenantFilter]) + + return ( + <> + + Copy library contents + + + {error ? {error} : null} + {!operationId ? ( + <> + + + setSourceSelectedLibrary(null)} + otherSiteId={destSelectedSite?.id} + otherListId={destSelectedLibrary?.Id ?? destSelectedLibrary?.id} + /> + + + setDestSelectedLibrary(null)} + otherSiteId={sourceSelectedSite?.id} + otherListId={sourceSelectedLibrary?.Id ?? sourceSelectedLibrary?.id} + /> + + + setConflictBehavior(e.target.value)} + sx={{ maxWidth: 280 }} + > + Replace existing items + Fail on conflict + + + Metadata and versions are preserved via MoveButKeepSource. Custom column values require + matching columns on the destination. File-level progress is not shown to protect tenant + content. + + + ) : ( + fetchStatus({ manual: true })} + /> + )} + + + + + {!operationId && canWrite ? ( + + ) : null} + + + + setConfirmOpen(false)} maxWidth="sm" fullWidth> + Confirm library copy + + + + From: {sourceSelectedSite?.displayName} / {sourceSelectedLibrary?.displayName} + + + To: {destSelectedSite?.displayName} / {destSelectedLibrary?.displayName} + + + Conflict: {conflictBehavior} + + + Estimated jobs: {preflight?.EligibleRootCount ?? '—'} + + {preflight?.WarnLevel === 'strong' ? ( + + This library has many root items ({preflight.EligibleRootCount}). Copy may be slow. + + ) : null} + {preflight?.WarnLevel === 'soft' ? ( + + Estimated {preflight.EligibleRootCount} SharePoint jobs will be queued. + + ) : null} + {conflictBehavior === 'Replace' ? ( + + Existing same-name items in the destination will be overwritten. This cannot be undone from + CIPP. + + ) : null} + + Contents merge into the destination library. Source is not deleted. Missing custom columns on + the destination drop those field values. + + + + + + + + + + ); +} + +CippSharePointLibraryCopyDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + tenantFilter: PropTypes.string, + sourceSite: PropTypes.shape({ + id: PropTypes.string, + webUrl: PropTypes.string, + displayName: PropTypes.string, + }), + sourceLibrary: PropTypes.shape({ + id: PropTypes.string, + displayName: PropTypes.string, + template: PropTypes.string, + }), +} diff --git a/src/components/CippComponents/CippSharePointPermissionEditor.jsx b/src/components/CippComponents/CippSharePointPermissionEditor.jsx index 477540f1868f..cc4cbc213c27 100644 --- a/src/components/CippComponents/CippSharePointPermissionEditor.jsx +++ b/src/components/CippComponents/CippSharePointPermissionEditor.jsx @@ -1,7 +1,7 @@ import { useFieldArray } from "react-hook-form"; +import { CippIcons } from "../../utils/icon-registry" import { Alert, Box, Button, IconButton, Stack, Typography, Tooltip } from "@mui/material"; import { Grid } from "@mui/system"; -import { Add, Delete } from "@mui/icons-material"; import CippFormComponent from "./CippFormComponent"; // SharePoint built-in permission levels. Used for both site-root and per-library grants. @@ -34,13 +34,20 @@ export const CippSharePointPermissionEditor = ({ target tenant, so the same template works everywhere. {fields.length === 0 && ( - + {emptyText} )} {fields.map((field, index) => ( - + remove(index)} > - + @@ -76,7 +83,7 @@ export const CippSharePointPermissionEditor = ({ + + + + ); +} + +CippSharePointRecycleFolderRestoreDialog.propTypes = { + open: PropTypes.bool, + onClose: PropTypes.func, + folderLabel: PropTypes.string, + items: PropTypes.array, + onConfirm: PropTypes.func, + isPending: PropTypes.bool, + error: PropTypes.any, +} diff --git a/src/components/CippComponents/CippSharePointTemplateBuilder.jsx b/src/components/CippComponents/CippSharePointTemplateBuilder.jsx index dd04ef6797b5..a029a10b8c66 100644 --- a/src/components/CippComponents/CippSharePointTemplateBuilder.jsx +++ b/src/components/CippComponents/CippSharePointTemplateBuilder.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { useFieldArray, useWatch, Controller } from "react-hook-form"; import { Box, @@ -20,19 +21,6 @@ import { Typography, Button, } from "@mui/material"; -import { - Add, - Category, - Delete, - Folder, - Label, - Lock, - MoreVert, - Translate, - VpnKey, - ViewColumn, - Web, -} from "@mui/icons-material"; import CippFormComponent from "./CippFormComponent"; import { CippSharePointPermissionEditor } from "./CippSharePointPermissionEditor"; import SharePointIcon from "../../icons/iconly/bulk/sharepoint"; @@ -201,7 +189,7 @@ const LibraryRow = ({ formControl, name, onRemove, onConfigurePermissions }) => }} > - + /> {permCount > 0 && ( 1 ? "s" : ""}`}> - + )} @@ -228,7 +216,7 @@ const LibraryRow = ({ formControl, name, onRemove, onConfigurePermissions }) => aria-label="Library actions" onClick={(e) => setAnchorEl(e.currentTarget)} > - + setAnchorEl(null)}> @@ -239,7 +227,7 @@ const LibraryRow = ({ formControl, name, onRemove, onConfigurePermissions }) => }} > - + {permCount > 0 ? "Edit Permissions" : "Add Permissions"} @@ -247,7 +235,7 @@ const LibraryRow = ({ formControl, name, onRemove, onConfigurePermissions }) => - + Add Column @@ -257,7 +245,7 @@ const LibraryRow = ({ formControl, name, onRemove, onConfigurePermissions }) => - Manage Metadata @@ -272,7 +260,7 @@ const LibraryRow = ({ formControl, name, onRemove, onConfigurePermissions }) => sx={{ color: "error.main" }} > - + Remove Library @@ -297,11 +285,15 @@ const SiteTypeDialog = ({ formControl, name, overrideActive, onClose }) => ( disabled={overrideActive} /> {overrideActive ? ( - + Template override is active — all sites use the template site type at deploy. ) : ( - + Choose whether this entry provisions a SharePoint site or a Microsoft Team. )} @@ -344,7 +336,9 @@ const SiteLanguageDialog = ({ formControl, name, onClose }) => { creatable={false} disableClearable={true} /> - + Tenant default follows each target tenant's SharePoint root site language (the SPO default for new sites) at deploy. A specific language is applied when the SharePoint site is created. @@ -384,7 +378,9 @@ const CreateAsDialog = ({ formControl, name, onClose }) => { formControl={formControl} options={CREATE_AS_OPTIONS} /> - + Team site is a collaboration workspace. Communication site is for publishing (intranet, news). Only applies when this card deploys as a SharePoint site. @@ -538,7 +534,7 @@ const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermi onClick={() => onConfigurePermissions()} sx={{ color: permCount > 0 ? "#fff" : "error.main" }} > - + @@ -548,7 +544,7 @@ const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermi onClick={(e) => setAnchorEl(e.currentTarget)} sx={{ color: "#fff" }} > - + @@ -579,7 +575,7 @@ const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermi }} > - + {permCount > 0 ? "Edit Site Permissions" : "Add Site Permissions"} @@ -592,7 +588,7 @@ const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermi }} > - + - + @@ -620,7 +616,7 @@ const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermi }} > - + @@ -634,7 +630,7 @@ const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermi sx={{ color: "error.main" }} > - + Remove Site Template @@ -702,7 +698,7 @@ const SiteTemplateCard = ({ formControl, name, index, onRemove, onConfigurePermi "&:hover": { bgcolor: "action.hover" }, }} > - + Add Library @@ -747,7 +743,9 @@ const AddSiteCard = ({ onAddSharePoint, onAddTeams }) => ( borderWidth: 2, }} > - + Add New Site Template @@ -792,7 +790,9 @@ const AddSiteCard = ({ onAddSharePoint, onAddTeams }) => ( // Small stat tile used by the Quick Stats panel. const Stat = ({ label, value }) => ( - + {label} {value} @@ -886,7 +886,9 @@ const SiteTypeOverrideDialog = ({ formControl, open, onClose }) => { Override site types - + Force every site in this template to deploy as the same type, ignoring each card's own site type. @@ -905,7 +907,9 @@ const SiteTypeOverrideDialog = ({ formControl, open, onClose }) => { disabled={!overrideActive} /> {!overrideActive && ( - + Turn on the override to choose the type applied to every site. )} @@ -948,16 +952,22 @@ export const CippSharePointTemplateBuilder = ({ formControl }) => { > Site Templates - + Each site template provisions a SharePoint site or Microsoft Team and its document libraries. {overrideActive && ( + sx={{ + color: "warning.main", + display: "flex", + alignItems: "center", + gap: 0.5, + mt: 0.75 + }}> Site type override active — all sites deploy as{" "} {resolveSiteType(templateSiteType) === "teams" ? "Microsoft Teams" : "SharePoint sites"}. @@ -970,7 +980,7 @@ export const CippSharePointTemplateBuilder = ({ formControl }) => { aria-label="Site template actions" onClick={(e) => setActionsAnchor(e.currentTarget)} > - + { }} > - + diff --git a/src/components/CippComponents/CippSharePointTemplateDeployDrawer.jsx b/src/components/CippComponents/CippSharePointTemplateDeployDrawer.jsx index a18f570c3ee9..9ce613e9a511 100644 --- a/src/components/CippComponents/CippSharePointTemplateDeployDrawer.jsx +++ b/src/components/CippComponents/CippSharePointTemplateDeployDrawer.jsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Button } from "@mui/material"; import { Grid } from "@mui/system"; import { useForm, useWatch } from "react-hook-form"; -import { RocketLaunch } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormTenantSelector } from "./CippFormTenantSelector"; @@ -58,9 +58,9 @@ export const CippSharePointTemplateDeployDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippSharePointVersionCleanupFields.jsx b/src/components/CippComponents/CippSharePointVersionCleanupFields.jsx new file mode 100644 index 000000000000..d08a3954d263 --- /dev/null +++ b/src/components/CippComponents/CippSharePointVersionCleanupFields.jsx @@ -0,0 +1,365 @@ +import PropTypes from 'prop-types' +import { useEffect } from 'react' +import { Controller, useWatch } from 'react-hook-form' +import { + Alert, + Box, + CircularProgress, + FormHelperText, + Slider, + Stack, + Typography, +} from '@mui/material' +import CippFormComponent from './CippFormComponent' +import { CippFormCondition } from './CippFormCondition' +import { ApiGetCall } from '../../api/ApiCall' + +/** Human-readable site version policy for Sync-policy cleanup. */ +export const formatVersionPolicy = (props) => { + if (!props || typeof props !== 'object') return null + if (props.InheritVersionPolicyFromTenant) { + const major = + props.MajorVersionLimit === null || props.MajorVersionLimit === undefined + ? null + : Number(props.MajorVersionLimit) + const days = + props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined + ? null + : Number(props.ExpireVersionsAfterDays) + const parts = ['Inherits tenant default'] + if (props.EnableAutoExpirationVersionTrim) { + parts.push('auto trim') + } else if (major !== null && !Number.isNaN(major) && major > 0) { + parts.push(`${major.toLocaleString()} major`) + if (days !== null && !Number.isNaN(days) && days > 0) { + parts.push(`expire after ${days.toLocaleString()} days`) + } + } + return parts.join(' · ') + } + + const major = + props.MajorVersionLimit === null || props.MajorVersionLimit === undefined + ? null + : Number(props.MajorVersionLimit) + const days = + props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined + ? null + : Number(props.ExpireVersionsAfterDays) + + if (props.EnableAutoExpirationVersionTrim) { + const parts = ['Auto trim (Microsoft managed)'] + if (major !== null && !Number.isNaN(major) && major > 0) { + parts.push(`${major.toLocaleString()} major`) + } + if (days !== null && !Number.isNaN(days) && days > 0) { + parts.push(`${days.toLocaleString()} days`) + } + return parts.join(' · ') + } + + if (major !== null && !Number.isNaN(major)) { + if (major <= 0) return 'Unlimited / not set' + const label = `${major.toLocaleString()} major versions` + if (days !== null && !Number.isNaN(days) && days > 0) { + return `${label} · expire after ${days.toLocaleString()} days` + } + return label + } + return null +} + +const optionValue = (value) => { + if (value && typeof value === 'object' && value.value !== undefined) return String(value.value) + if (value === null || value === undefined) return null + return String(value) +} + +const DAYS_MARKS = [ + { value: 30, label: '30' }, + { value: 90, label: '90' }, + { value: 180, label: '180' }, + { value: 365, label: '365' }, +] + +const VERSION_MARKS = [ + { value: 1, label: '1' }, + { value: 10, label: '10' }, + { value: 25, label: '25' }, + { value: 50, label: '50' }, + { value: 100, label: '100' }, +] + +const VersionCleanupSlider = ({ + formHook, + name, + label, + min, + max, + step = 1, + marks, + valueLabelFormat, + defaultValue, + rules, +}) => ( + { + const numeric = Number(field.value) + const safeValue = Number.isFinite(numeric) + ? Math.min(max, Math.max(min, numeric)) + : defaultValue + return ( + + + + {label} + + + {valueLabelFormat ? valueLabelFormat(safeValue) : safeValue} + + + field.onChange(next)} + onBlur={field.onBlur} + name={field.name} + aria-label={label} + /> + {fieldState.error?.message ? ( + {fieldState.error.message} + ) : null} + + ) + }} + /> +) + +VersionCleanupSlider.propTypes = { + formHook: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + min: PropTypes.number.isRequired, + max: PropTypes.number.isRequired, + step: PropTypes.number, + marks: PropTypes.array, + valueLabelFormat: PropTypes.func, + defaultValue: PropTypes.number.isRequired, + rules: PropTypes.object, +} + +/** + * Shared version-cleanup mode fields for Storage Report cleanup and site capacity dialog. + * When Sync policy is selected and siteUrl/tenantFilter are provided, shows the current + * site version policy so the admin is not flying blind. + */ +export const CippSharePointVersionCleanupFields = ({ + formHook, + tenantFilter, + siteUrl, +}) => { + const mode = optionValue(useWatch({ control: formHook.control, name: 'BatchDeleteMode' })) + const showPolicy = mode === '2' + const canLoadPolicy = showPolicy && !!tenantFilter && !!siteUrl + const majorLimit = Number( + useWatch({ control: formHook.control, name: 'MajorVersionLimit', defaultValue: 50 }) + ) + const majorWithMinor = Number( + useWatch({ + control: formHook.control, + name: 'MajorWithMinorVersionsLimit', + defaultValue: 0, + }) + ) + const minorSliderMax = Number.isFinite(majorLimit) && majorLimit > 0 ? majorLimit : 100 + + useEffect(() => { + if (!Number.isFinite(majorWithMinor) || !Number.isFinite(minorSliderMax)) return + if (majorWithMinor > minorSliderMax) { + formHook.setValue('MajorWithMinorVersionsLimit', minorSliderMax, { + shouldValidate: true, + shouldDirty: true, + }) + } + }, [formHook, majorWithMinor, minorSliderMax]) + + const siteProps = ApiGetCall({ + url: '/api/ListSiteProperties', + data: { tenantFilter, SiteUrl: siteUrl }, + queryKey: `VersionCleanupPolicy-${tenantFilter}-${siteUrl}`, + waiting: canLoadPolicy, + }) + + const props = + siteProps.data && typeof siteProps.data === 'object' && !Array.isArray(siteProps.data) + ? siteProps.data + : null + const policyLabel = formatVersionPolicy(props) + const policyError = + typeof siteProps.data === 'string' + ? siteProps.data + : siteProps.isError + ? 'Could not load site version policy.' + : null + + return ( + <> + + + + {!canLoadPolicy ? ( + + Sync policy trims existing versions to this site's current version limits. + Select a single site to preview those limits here. + + ) : siteProps.isFetching && !props ? ( + + + Loading this site's version policy… + + ) : policyError ? ( + {policyError} + ) : ( + + + Current site policy + + + {policyLabel || 'No version policy details returned for this site.'} + + + This job will trim existing version history to match that policy. It does not change + the policy itself — use the SPOVersionControl standard (or Edit Site) for that. + + + )} + + + + `${v} days`} + rules={{ + required: 'Choose how many days', + min: { value: 30, message: 'SharePoint requires at least 30 days' }, + max: { value: 365, message: 'Use Sync policy or Count limits for longer retention' }, + }} + /> + + + `${v}`} + rules={{ + required: 'Choose a version limit', + min: { value: 1, message: 'Keep at least 1 major version' }, + max: { value: 100, message: 'Maximum for this control is 100' }, + }} + /> + = 10 ? [{ value: 10, label: '10' }] : []), + ...(minorSliderMax >= 25 ? [{ value: 25, label: '25' }] : []), + ...(minorSliderMax >= 50 ? [{ value: 50, label: '50' }] : []), + ...(minorSliderMax > 0 + ? [{ value: minorSliderMax, label: String(minorSliderMax) }] + : []), + ]} + defaultValue={0} + valueLabelFormat={(v) => `${v}`} + rules={{ + required: 'Choose how many majors keep minors', + min: { value: 0, message: 'Cannot be negative' }, + validate: (value) => { + const major = Number(formHook.getValues('MajorVersionLimit')) + const minor = Number(value) + if (!Number.isFinite(minor)) return 'Choose how many majors keep minors' + if (Number.isFinite(major) && minor > major) { + return 'Cannot exceed the major version limit' + } + return true + }, + }} + /> + + + ) +} + +CippSharePointVersionCleanupFields.propTypes = { + formHook: PropTypes.object.isRequired, + tenantFilter: PropTypes.string, + siteUrl: PropTypes.string, +} + +export default CippSharePointVersionCleanupFields diff --git a/src/components/CippComponents/CippSharedMailboxDrawer.jsx b/src/components/CippComponents/CippSharedMailboxDrawer.jsx index 2efaed2a67f7..b463dcbe4138 100644 --- a/src/components/CippComponents/CippSharedMailboxDrawer.jsx +++ b/src/components/CippComponents/CippSharedMailboxDrawer.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Button } from "@mui/material"; import { useForm } from "react-hook-form"; -import { Add } from "@mui/icons-material"; import { CippOffCanvas } from "./CippOffCanvas"; import CippFormComponent from "./CippFormComponent"; import { CippFormDomainSelector } from "./CippFormDomainSelector"; @@ -71,9 +71,9 @@ export const CippSharedMailboxDrawer = ({ return ( <> setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippSitRulePackDetails.jsx b/src/components/CippComponents/CippSitRulePackDetails.jsx index ff7d65f456a0..42116495bb7c 100644 --- a/src/components/CippComponents/CippSitRulePackDetails.jsx +++ b/src/components/CippComponents/CippSitRulePackDetails.jsx @@ -27,13 +27,21 @@ export const CippSitRulePackDetails = ({ row, tenant }) => { if (rulePack.isLoading || rulePack.isFetching) { return ( - + - + Looking up rule pack {row?.RulePackId}... - ) + ); } if (rulePack.isError || !rulePack.data?.Xml) { diff --git a/src/components/CippComponents/CippSitTemplateDetails.jsx b/src/components/CippComponents/CippSitTemplateDetails.jsx index 001cae8d0059..f81e72472f62 100644 --- a/src/components/CippComponents/CippSitTemplateDetails.jsx +++ b/src/components/CippComponents/CippSitTemplateDetails.jsx @@ -92,7 +92,9 @@ export const CippSitTemplateDetails = ({ row }) => { return ( - + {isAdvanced ? 'Advanced template — the captured rule pack is stored as base64. The decoded detection config and XML below are exactly what gets deployed.' : 'Simple template — the backend synthesizes a rule pack from this pattern at deploy time.'} @@ -136,5 +138,5 @@ export const CippSitTemplateDetails = ({ row }) => { )} - ) + ); } diff --git a/src/components/CippComponents/CippSiteRecycleBinDialog.jsx b/src/components/CippComponents/CippSiteRecycleBinDialog.jsx index 6e1ecb6b65ab..9e3f4b5a7fc9 100644 --- a/src/components/CippComponents/CippSiteRecycleBinDialog.jsx +++ b/src/components/CippComponents/CippSiteRecycleBinDialog.jsx @@ -1,5 +1,5 @@ import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material' -import { RestoreFromTrash } from '@mui/icons-material' +import { CippIcons } from '../../utils/icon-registry' import { CippDataTable } from '../CippTable/CippDataTable' import { usePermissions } from '../../hooks/use-permissions' @@ -21,7 +21,7 @@ export const CippSiteRecycleBinDialog = ({ { label: 'Restore Item', type: 'POST', - icon: , + icon: , url: '/api/ExecRestoreRecycleBinItems', data: { Ids: 'Id', diff --git a/src/components/CippComponents/CippSpeedDial.jsx b/src/components/CippComponents/CippSpeedDial.jsx index faa9b1ac3721..4eb84f5bce3b 100644 --- a/src/components/CippComponents/CippSpeedDial.jsx +++ b/src/components/CippComponents/CippSpeedDial.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { SpeedDial, SpeedDialAction, @@ -13,7 +14,6 @@ import { CircularProgress, useMediaQuery, } from '@mui/material' -import { Close as CloseIcon } from '@mui/icons-material' import { useForm } from 'react-hook-form' import { CippFormComponent } from '../../components/CippComponents/CippFormComponent' @@ -21,7 +21,7 @@ const CippSpeedDial = ({ actions = [], position = { bottom: 16, right: 16 }, icon, - openIcon = , + openIcon = , }) => { const [openDialogs, setOpenDialogs] = useState({}) const [loading, setLoading] = useState(false) @@ -144,7 +144,7 @@ const CippSpeedDial = ({ { if (action.form) { handleDialogOpen(action.id) @@ -153,7 +153,6 @@ const CippSpeedDial = ({ } setSpeedDialOpen(false) }} - tooltipOpen sx={{ '&.MuiSpeedDialAction-fab': { backgroundColor: 'background.paper', diff --git a/src/components/CippComponents/CippSponsor.jsx b/src/components/CippComponents/CippSponsor.jsx index 1602c00a0942..6e8c4c451749 100644 --- a/src/components/CippComponents/CippSponsor.jsx +++ b/src/components/CippComponents/CippSponsor.jsx @@ -72,15 +72,14 @@ export const CippSponsor = ({ compact = false }) => { <> + mt: compact ? 0.75 : 2 + }}> This application is sponsored by + value && typeof value === 'object' && 'value' in value ? value.value : value + +const TYPE_LABEL = { + versions: 'Versions', + recycle: 'Recycle', + largeLibrary: 'Large library', +} + +/** + * Discover → preview → action drawer for Storage Report cleanup (library ceiling). + */ +export const CippStorageCleanupDrawer = ({ + open, + onClose, + tenantFilter, + sites = [], + focusItem = null, + initialAction = null, +}) => { + const { checkPermissions } = usePermissions() + const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite']) + const canReadRecycle = checkPermissions([ + 'Sharepoint.SiteRecycleBin.Read', + 'Sharepoint.SiteRecycleBin.ReadWrite', + ]) + const canEmptyRecycle = checkPermissions(['Sharepoint.SiteRecycleBin.ReadWrite']) + + const [tab, setTab] = useState(0) + const [activeSiteUrl, setActiveSiteUrl] = useState('') + const versionDialog = useDialog() + const recycleDialog = useDialog() + const jobStatusApi = ApiPostCall({}) + + const siteList = useMemo(() => { + const map = new Map() + for (const s of sites) { + if (s?.webUrl) map.set(s.webUrl.replace(/\/+$/, ''), s) + } + if (focusItem?.siteUrl) { + const key = focusItem.siteUrl.replace(/\/+$/, '') + if (!map.has(key) && focusItem.site) map.set(key, focusItem.site) + } + return [...map.values()] + }, [sites, focusItem]) + + useEffect(() => { + if (!open) return + const preferred = + focusItem?.siteUrl || + siteList[0]?.webUrl || + '' + setActiveSiteUrl(preferred) + if (initialAction === 'emptyRecycle' || focusItem?.type === 'recycle') setTab(1) + else setTab(0) + }, [open, focusItem, siteList, initialAction]) + + const activeSite = siteList.find( + (s) => s.webUrl?.replace(/\/+$/, '') === activeSiteUrl?.replace(/\/+$/, '') + ) || siteList[0] + + const siteUrl = activeSite?.webUrl + const siteName = activeSite?.displayName || siteUrl || 'Site' + + const librariesApi = ApiGetCall({ + url: '/api/ListSiteBrowser', + data: { tenantFilter, SiteUrl: siteUrl }, + queryKey: `CleanupLibs-${tenantFilter}-${siteUrl}`, + waiting: open && !!tenantFilter && !!siteUrl, + }) + + const compositionApi = ApiGetCall({ + url: '/api/ListSiteStorageComposition', + data: { tenantFilter, SiteUrl: siteUrl }, + queryKey: `CleanupComposition-${tenantFilter}-${siteUrl}`, + waiting: open && !!tenantFilter && !!siteUrl, + }) + + const recycleApi = ApiGetCall({ + url: '/api/ListSiteRecycleBinSummary', + data: { tenantFilter, SiteUrl: siteUrl }, + queryKey: `CleanupRecycle-${tenantFilter}-${siteUrl}`, + waiting: open && canReadRecycle && !!tenantFilter && !!siteUrl, + }) + + const libraries = useMemo(() => { + const rows = Array.isArray(librariesApi.data?.Results) ? librariesApi.data.Results : [] + return [...rows].sort( + (a, b) => (Number(b.versionEstimateBytes) || 0) - (Number(a.versionEstimateBytes) || 0) + ) + }, [librariesApi.data]) + + const composition = compositionApi.data?.Results + const recycle = recycleApi.data?.Results + + const tipBytes = Number(composition?.tipBytes) || 0 + const versionBytes = Number(composition?.versionEstimateBytes) || 0 + const recycleBytes = Number(recycle?.totalBytes ?? composition?.recycleEstimateBytes) || 0 + const compositionTotal = tipBytes + versionBytes + recycleBytes || 1 + + useEffect(() => { + if (!open || tab !== 0 || !siteUrl || !tenantFilter) return + jobStatusApi.mutate({ + url: '/api/ListSPOVersionCleanup', + data: { tenantFilter, SiteUrl: siteUrl }, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, tab, siteUrl, tenantFilter]) + + const refreshPreview = () => { + librariesApi.refetch?.() + compositionApi.refetch?.() + recycleApi.refetch?.() + if (siteUrl) { + jobStatusApi.mutate({ + url: '/api/ListSPOVersionCleanup', + data: { tenantFilter, SiteUrl: siteUrl }, + }) + } + } + + return ( + <> + + + + + + Storage cleanup + + + + + + + {focusItem ? ( + + ) : null} + + {siteList.length > 1 ? ( + setActiveSiteUrl(e.target.value)} + fullWidth + > + {siteList.map((s) => ( + + {s.displayName || s.webUrl} + + ))} + + ) : ( + {siteName} + )} + + + Estimates from StorageMetrics (tip / versions) and recycle aggregates. No file names. + Version trim reclaim may differ until the job reports Storage Released. + + + {(compositionApi.isFetching || librariesApi.isFetching) && } + + + + Composition (estimate) + + {[ + { label: 'Current files (tip)', bytes: tipBytes }, + { label: 'Previous versions', bytes: versionBytes }, + { label: 'Recycle bin', bytes: recycleBytes }, + ].map((row) => ( + + + {row.label} + {formatCleanupBytes(row.bytes)} + + + + ))} + + + + + setTab(v)} variant="fullWidth"> + + + + + {tab === 0 && ( + + + Top libraries by version estimate + + {librariesApi.isFetching ? ( + + ) : ( + + + + Library + Total + Versions (est.) + + + + {libraries.slice(0, 12).map((lib) => ( + + {lib.displayName || lib.name} + + {formatCleanupBytes(lib.storageUsedInBytes)} + + + {formatCleanupBytes(lib.versionEstimateBytes)} + + + ))} + {libraries.length === 0 ? ( + + No libraries loaded + + ) : null} + +
+ )} + {canWriteSite ? ( + + ) : null} + {jobStatusApi.data?.Results || jobStatusApi.data ? ( + + Job status:{' '} + {typeof (jobStatusApi.data?.Results ?? jobStatusApi.data) === 'object' + ? (jobStatusApi.data?.Results ?? jobStatusApi.data)?.Status || + JSON.stringify(jobStatusApi.data?.Results ?? jobStatusApi.data).slice(0, 120) + : String(jobStatusApi.data?.Results ?? jobStatusApi.data)} + + ) : null} +
+ )} + + {tab === 1 && ( + + {recycleApi.isFetching ? ( + + ) : recycle && typeof recycle === 'object' && recycle.totalBytes !== undefined ? ( + <> + + Recycle total: {formatCleanupBytes(recycle.totalBytes)} + {recycle.capped ? ' (scan capped)' : ''} + + + First stage: {recycle.firstStageCount?.toLocaleString?.() ?? recycle.firstStageCount}{' '} + items · {formatCleanupBytes(recycle.firstStageBytes)} + + + Second stage: {recycle.secondStageCount?.toLocaleString?.() ?? recycle.secondStageCount}{' '} + items · {formatCleanupBytes(recycle.secondStageBytes)} + + + ) : ( + + {typeof recycleApi.data?.Results === 'string' + ? recycleApi.data.Results + : 'Could not load recycle summary.'} + + )} + {canEmptyRecycle ? ( + + ) : ( + You need Recycle Bin write permission to empty. + )} + + )} + + + +
+
+ + { + const mode = parseInt(optionValue(formData.BatchDeleteMode) ?? '2', 10) + return { + tenantFilter, + SiteUrl: siteUrl, + BatchDeleteMode: mode, + DeleteOlderThanDays: mode === 0 ? parseInt(formData.DeleteOlderThanDays, 10) : -1, + MajorVersionLimit: mode === 1 ? parseInt(formData.MajorVersionLimit, 10) : -1, + MajorWithMinorVersionsLimit: + mode === 1 ? parseInt(formData.MajorWithMinorVersionsLimit, 10) : -1, + } + }, + multiPost: false, + onSuccess: () => { + jobStatusApi.mutate({ + url: '/api/ListSPOVersionCleanup', + data: { tenantFilter, SiteUrl: siteUrl }, + }) + }, + }} + row={activeSite ?? {}} + > + {({ formHook }) => ( + + )} + + + ({ + tenantFilter, + SiteUrl: siteUrl, + Stage: optionValue(formData.Stage) || 'Both', + }), + multiPost: false, + onSuccess: () => { + recycleApi.refetch?.() + compositionApi.refetch?.() + }, + }} + row={activeSite ?? {}} + > + {({ formHook }) => ( + + )} + + + ) +} + +CippStorageCleanupDrawer.propTypes = { + open: PropTypes.bool, + onClose: PropTypes.func, + tenantFilter: PropTypes.string, + sites: PropTypes.array, + focusItem: PropTypes.object, + initialAction: PropTypes.string, +} + +export default CippStorageCleanupDrawer diff --git a/src/components/CippComponents/CippSupportBundleDialog.jsx b/src/components/CippComponents/CippSupportBundleDialog.jsx index e90efc21796f..5e3b024b4f98 100644 --- a/src/components/CippComponents/CippSupportBundleDialog.jsx +++ b/src/components/CippComponents/CippSupportBundleDialog.jsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Alert, Button, @@ -13,12 +14,6 @@ import { Switch, Typography, } from '@mui/material' -import { - Download, - FiberManualRecord, - PlayArrow, - Stop, -} from '@mui/icons-material' import { useQueryClient } from '@tanstack/react-query' import { useSettings } from '../../hooks/use-settings' import { @@ -233,14 +228,18 @@ const CippSupportBundleDialog = ({ open, onClose, onRecordingChange }) => { )} {phase === 'recording' && ( - - + + Recording — {progress} request{progress === 1 ? '' : 's'}{' '} captured so far. - + Close this dialog, reproduce the issue, then click the recording indicator to come back and stop. Reloading the browser discards the recording. @@ -248,9 +247,16 @@ const CippSupportBundleDialog = ({ open, onClose, onRecordingChange }) => { )} {phase === 'collecting' && ( - + - + Collecting — {progress} request{progress === 1 ? '' : 's'}{' '} captured... @@ -294,14 +300,14 @@ const CippSupportBundleDialog = ({ open, onClose, onRecordingChange }) => { <> + sx={{ + color: "text.secondary", + ml: 'auto' + }}> Showing {filteredItems.length} of {allItems.length} templates @@ -885,7 +887,7 @@ export const CippTemplateCatalog = ({ + + + + + + {pkg.templates.map((tpl) => ( + + {getDisplayName(tpl, typeConfig)} + + + removeMemberMutation.mutate({ + url: '/api/ExecSetPackageTag', + data: { GUID: [tpl.GUID], Remove: true }, + }) + } + > + + + + + ))} + + + + + ))} + + + + + {/* Rename package dialog */} + setRenameTarget(null)}> + Rename Package + + + + Renames the package tag on all {renameTarget?.templateCount} member template(s). + + setRenameValue(value?.value ?? '')} + options={packageList + .filter((p) => p.value !== renameTarget?.value) + .map((p) => ({ label: p.value, value: p.value }))} + /> + + + + + + + + + + {/* Delete package dialog */} + setDeleteTarget(null)}> + Delete Package + + + Are you sure you want to delete the package '{deleteTarget?.value}'? Its{' '} + {deleteTarget?.templateCount} member template(s) are not deleted — they are just + removed from the package. + + + + + + + + +
+ ); +} diff --git a/src/components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx b/src/components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx index 0e28dce6d188..5b566fe6a250 100644 --- a/src/components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx +++ b/src/components/CippComponents/CippTenantAllowBlockListTemplateDrawer.jsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Button, Divider } from '@mui/material' import { Grid } from '@mui/system' import { useForm, useFormState, useWatch } from 'react-hook-form' -import { SaveAlt } from '@mui/icons-material' import { CippOffCanvas } from './CippOffCanvas' import CippFormComponent from './CippFormComponent' import { CippApiResults } from './CippApiResults' @@ -248,9 +248,9 @@ export const CippTenantAllowBlockListTemplateDrawer = ({ <> {!isEditMode && controlledDrawerVisible === undefined && ( setDrawerVisible(true)} - startIcon={} + startIcon={} > {buttonText} diff --git a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx index 490818307141..d8232ceb3576 100644 --- a/src/components/CippComponents/CippTenantGroupOffCanvas.jsx +++ b/src/components/CippComponents/CippTenantGroupOffCanvas.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Box, Typography, @@ -10,7 +11,6 @@ import { useTheme, Stack, } from "@mui/material"; -import { Groups, Business, Rule, Info } from "@mui/icons-material"; import { CippDataTable } from "../CippTable/CippDataTable"; export const CippTenantGroupOffCanvas = ({ data }) => { @@ -114,7 +114,9 @@ export const CippTenantGroupOffCanvas = ({ data }) => { borderRadius: 1, }} > - + Rule {rules.length > 1 ? `${index + 1}:` : "Configuration:"} @@ -161,7 +163,7 @@ export const CippTenantGroupOffCanvas = ({ data }) => { gutterBottom sx={{ display: "flex", alignItems: "center", gap: 1 }} > - + Dynamic Rules {rules.length > 1 && ( { }} title={"Members"} cardHeaderProps={{ - avatar: , + avatar: , }} /> @@ -214,7 +216,7 @@ export const CippTenantGroupOffCanvas = ({ data }) => { {/* Header Section */} - + {data.Name} @@ -225,7 +227,9 @@ export const CippTenantGroupOffCanvas = ({ data }) => { color={isDynamic ? "primary" : "secondary"} variant="filled" /> - + ID: {data.Id} @@ -256,7 +260,7 @@ export const CippTenantGroupOffCanvas = ({ data }) => { gutterBottom sx={{ display: "flex", alignItems: "center", gap: 1 }} > - + Additional Information { }} > - + Group Type {isDynamic ? "Dynamic" : "Static"} - + Member Count @@ -283,7 +291,9 @@ export const CippTenantGroupOffCanvas = ({ data }) => { {isDynamic && ( <> - + Rule Logic @@ -291,7 +301,9 @@ export const CippTenantGroupOffCanvas = ({ data }) => { - + Has Rules {hasDynamicRules ? "Yes" : "No"} diff --git a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx index 34ff4e5ccf5a..f47a3a5bc2c8 100644 --- a/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx +++ b/src/components/CippComponents/CippTenantGroupRuleBuilder.jsx @@ -1,7 +1,7 @@ import React, { useState, useMemo } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Box, Button, IconButton, Typography, Alert, Paper } from "@mui/material"; import { Grid } from "@mui/system"; -import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material"; import CippFormComponent from "./CippFormComponent"; import { CippFormCondition } from "./CippFormCondition"; import { useWatch } from "react-hook-form"; @@ -102,7 +102,9 @@ const CippTenantGroupRuleBuilder = ({ formControl, name = "dynamicRules" }) => { )} - + {/* Property Selection */} { {canRemove && ( removeRule(ruleIndex)} size="small"> - + )} @@ -269,7 +271,7 @@ const CippTenantGroupRuleBuilder = ({ formControl, name = "dynamicRules" }) => { {/* Add Rule Button */} - diff --git a/src/components/CippComponents/CippTenantLookup.jsx b/src/components/CippComponents/CippTenantLookup.jsx index a1f8b3680cfc..7fcf529ca237 100644 --- a/src/components/CippComponents/CippTenantLookup.jsx +++ b/src/components/CippComponents/CippTenantLookup.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Box, Button, @@ -12,13 +13,6 @@ import { TextField, InputAdornment, } from "@mui/material"; -import { - Search, - Public, - Language, - LocationOn, - Cloud, -} from "@mui/icons-material"; import { useForm, useWatch } from "react-hook-form"; import CippButtonCard from "../CippCards/CippButtonCard"; import { ApiGetCall } from "../../api/ApiCall"; @@ -29,20 +23,20 @@ const getRegionIcon = (region) => { const regionUpper = region?.toUpperCase(); switch (regionUpper) { case "EU": - return ; + return ; case "US": - return ; + return ; case "ASIA": - return ; + return ; case "GCC": case "GCC-HIGH": - return ; + return ; case "DE": - return ; + return ; case "CN": - return ; + return ; default: - return ; + return ; } }; @@ -210,28 +204,30 @@ export const CippTenantLookup = () => { placeholder="Domain name or tenant ID" value={domain || ""} onChange={(e) => formControl.setValue("domain", e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - sx: { - "& .MuiInputAdornment-root": { - marginTop: "0 !important", - alignSelf: "center", + slotProps={{ + input: { + startAdornment: ( + + + + ), + sx: { + "& .MuiInputAdornment-root": { + marginTop: "0 !important", + alignSelf: "center", + }, }, - }, + } }} /> - ) + ); } export default CippTutorialDialog diff --git a/src/components/CippComponents/CippUserActions.jsx b/src/components/CippComponents/CippUserActions.jsx index 9685d5744713..6b767ba2706b 100644 --- a/src/components/CippComponents/CippUserActions.jsx +++ b/src/components/CippComponents/CippUserActions.jsx @@ -1,33 +1,11 @@ import { useEffect } from 'react' -import { EyeIcon, MagnifyingGlassIcon, TrashIcon } from '@heroicons/react/24/outline' -import { - Archive, - Clear, - CloudDone, - ContentCopy, - Edit, - Email, - ForwardToInbox, - GroupAdd, - LockClock, - LockPerson, - LockReset, - MeetingRoom, - Password, - PersonOff, - PhonelinkLock, - PhonelinkSetup, - Refresh, - Shortcut, - EditAttributes, - CloudSync, - Share, -} from '@mui/icons-material' +import { CippIcons } from '../../utils/icon-registry' import { getCippLicenseTranslation } from '../../utils/get-cipp-license-translation' import { useSettings } from '../../hooks/use-settings.js' import { usePermissions } from '../../hooks/use-permissions' import { Tooltip, Box, Divider, Typography, Alert, Skeleton, Link, IconButton } from '@mui/material' import CippFormComponent from './CippFormComponent' +import { MfaVerifyForm } from './CippMfaVerifyForm' import { CippFormCondition } from './CippFormCondition' import { useWatch } from 'react-hook-form' import { ApiGetCall } from '../../api/ApiCall' @@ -240,7 +218,7 @@ const TemporaryAccessPassForm = ({ formControl, row }) => { onClick={() => tapPolicy.refetch()} disabled={tapPolicy.isFetching} > - + @@ -310,7 +288,23 @@ const TemporaryAccessPassForm = ({ formControl, row }) => { } // Separate component for Out of Office form to avoid hook issues -const OutOfOfficeForm = ({ formControl }) => { +export const OutOfOfficeForm = ({ formControl, row }) => { + const tenantFilter = useSettings().currentTenant + const rowData = Array.isArray(row) ? row[0] : row + const tenant = tenantFilter === 'AllTenants' && rowData?.Tenant ? rowData.Tenant : tenantFilter + // Only prefill for a single selected user; with multiple users there is no single current value + const singleUserUpn = + (!Array.isArray(row) || row.length === 1) && rowData?.userPrincipalName + ? rowData.userPrincipalName + : null + + const currentOoO = ApiGetCall({ + url: '/api/ListOoO', + data: { UserId: singleUserUpn, tenantFilter: tenant }, + queryKey: `ListOoO-${singleUserUpn}-${tenant}`, + waiting: !!singleUserUpn, + }) + // Send the browser's IANA timezone so the API can display local times in the response useEffect(() => { try { @@ -320,6 +314,35 @@ const OutOfOfficeForm = ({ formControl }) => { } }, []) + useEffect(() => { + const data = currentOoO.data + if (!data?.AutoReplyState) return + // Deferred a tick: CippApiDialog resets the form in a mount effect that runs after + // this child effect, so an immediate setValue would be wiped when the query is cached + const timer = setTimeout(() => { + formControl.setValue('AutoReplyState', { + label: data.AutoReplyState, + value: data.AutoReplyState, + }) + formControl.setValue('InternalMessage', data.InternalMessage || '') + formControl.setValue('ExternalMessage', data.ExternalMessage || '') + formControl.setValue( + 'StartTime', + data.StartTime ? new Date(data.StartTime).getTime() / 1000 : null + ) + formControl.setValue('EndTime', data.EndTime ? new Date(data.EndTime).getTime() / 1000 : null) + formControl.setValue('CreateOOFEvent', data.CreateOOFEvent === true) + formControl.setValue('OOFEventSubject', data.OOFEventSubject || '') + formControl.setValue( + 'AutoDeclineFutureRequestsWhenOOF', + data.AutoDeclineFutureRequestsWhenOOF === true + ) + formControl.setValue('DeclineEventsForScheduledOOF', data.DeclineEventsForScheduledOOF === true) + formControl.setValue('DeclineMeetingMessage', data.DeclineMeetingMessage || '') + }, 0) + return () => clearTimeout(timer) + }, [currentOoO.dataUpdatedAt]) + // Watch the Auto Reply State value const autoReplyState = useWatch({ control: formControl.control, @@ -329,6 +352,18 @@ const OutOfOfficeForm = ({ formControl }) => { // Calculate if date fields should be disabled const areDateFieldsDisabled = autoReplyState?.value !== 'Scheduled' + if (singleUserUpn && currentOoO.isLoading) { + return ( + <> + + + + + + + ) + } + return ( <> { //tested label: 'View User', link: '/identity/administration/users/user?userId=[id]', + pinned: true, multiPost: false, - icon: , + icon: , color: 'success', }, { //tested label: 'Edit User', link: '/identity/administration/users/user/edit?userId=[id]', - icon: , + pinned: true, + icon: , color: 'success', target: '_self', condition: () => canWriteUser, }, + { + label: 'View in Entra', + link: 'https://entra.microsoft.com/[Tenant]/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/[id]', + pinned: true, + icon: , + color: 'info', + target: '_blank', + multiPost: false, + external: true, + }, { label: 'Create Template from User', type: 'POST', - icon: , + icon: , url: '/api/AddUserDefaults', fields: [ { @@ -541,7 +588,7 @@ export const useCippUserActions = () => { //tested label: 'Research Compromised Account', type: 'GET', - icon: , + icon: , link: '/identity/administration/users/user/bec?userId=[id]', confirmText: 'Are you sure you want to research if [userPrincipalName] is a compromised account?', @@ -551,7 +598,7 @@ export const useCippUserActions = () => { //tested label: 'Create Temporary Access Pass', type: 'POST', - icon: , + icon: , url: '/api/ExecCreateTAP', data: { ID: 'userPrincipalName' }, children: ({ formHook, row }) => , @@ -565,7 +612,7 @@ export const useCippUserActions = () => { //tested label: 'Re-require MFA registration', type: 'POST', - icon: , + icon: , url: '/api/ExecResetMFA', data: { ID: 'userPrincipalName' }, confirmText: 'Are you sure you want to reset MFA for [userPrincipalName]?', @@ -576,17 +623,18 @@ export const useCippUserActions = () => { //tested label: 'Send MFA Push', type: 'POST', - icon: , + icon: , url: '/api/ExecSendPush', data: { UserEmail: 'userPrincipalName' }, - confirmText: 'Are you sure you want to send an MFA request to [userPrincipalName]?', + children: ({ formHook, row }) => , + confirmText: 'Send an MFA request to [userPrincipalName]?', multiPost: false, }, { //tested label: 'Set Per-User MFA', type: 'POST', - icon: , + icon: , url: '/api/ExecPerUserMFA', data: { userId: 'id', userPrincipalName: 'userPrincipalName' }, fields: [ @@ -612,7 +660,7 @@ export const useCippUserActions = () => { //tested label: 'Convert Mailbox', type: 'POST', - icon: , + icon: , url: '/api/ExecConvertMailbox', data: { ID: 'userPrincipalName' }, fields: [ @@ -637,7 +685,7 @@ export const useCippUserActions = () => { //tested label: 'Enable Online Archive', type: 'POST', - icon: , + icon: , url: '/api/ExecEnableArchive', data: { ID: 'userPrincipalName' }, confirmText: 'Are you sure you want to enable the online archive for [userPrincipalName]?', @@ -648,13 +696,15 @@ export const useCippUserActions = () => { //tested label: 'Set Out of Office', type: 'POST', - icon: , + icon: , url: '/api/ExecSetOoO', data: { userId: 'userPrincipalName', tenantFilter: 'Tenant', }, - children: ({ formHook: formControl }) => , + children: ({ formHook: formControl, row }) => ( + + ), confirmText: 'Are you sure you want to set the out of office?', multiPost: false, condition: () => canWriteMailbox, @@ -662,7 +712,7 @@ export const useCippUserActions = () => { { label: 'Add to Group', type: 'POST', - icon: , + icon: , url: '/api/EditGroup', customDataformatter: (row, action, formData) => { // Build the member list from selected users @@ -712,10 +762,14 @@ export const useCippUserActions = () => { validators: { required: 'Please select at least one group' }, api: { url: '/api/ListGroups', - labelField: (option) => - option?.calculatedGroupType - ? `${option.displayName} (${option.calculatedGroupType})` - : (option?.displayName ?? ''), + labelField: (option) => { + const name = option?.mail + ? `${option.displayName} - ${option.mail}` + : (option?.displayName ?? '') + return option?.calculatedGroupType + ? `${name} (${option.calculatedGroupType})` + : name + }, valueField: 'id', addedField: { groupType: 'groupType', @@ -735,7 +789,7 @@ export const useCippUserActions = () => { label: 'Manage Licenses', type: 'POST', url: '/api/ExecBulkLicense', - icon: , + icon: , data: { userIds: 'id' }, multiPost: true, allowResubmit: true, @@ -749,7 +803,7 @@ export const useCippUserActions = () => { label: 'Disable Email Forwarding', type: 'POST', url: '/api/ExecEmailForward', - icon: , + icon: , data: { username: 'userPrincipalName', userid: 'userPrincipalName', @@ -762,7 +816,7 @@ export const useCippUserActions = () => { { label: 'Pre-provision OneDrive', type: 'POST', - icon: , + icon: , url: '/api/ExecOneDriveProvision', data: { UserPrincipalName: 'userPrincipalName' }, confirmText: 'Are you sure you want to pre-provision OneDrive for [userPrincipalName]?', @@ -772,7 +826,7 @@ export const useCippUserActions = () => { { label: 'Set OneDrive External Sharing', type: 'POST', - icon: , + icon: , url: '/api/ExecSetOneDriveSharing', data: { UPN: 'userPrincipalName' }, fields: [ @@ -807,7 +861,7 @@ export const useCippUserActions = () => { { label: 'Add OneDrive Shortcut', type: 'POST', - icon: , + icon: , url: '/api/ExecOneDriveShortCut', data: { username: 'userPrincipalName', @@ -837,7 +891,7 @@ export const useCippUserActions = () => { { label: 'Set Sign In State', type: 'POST', - icon: , + icon: , url: '/api/ExecDisableUser', data: { ID: 'id' }, // Pre-select the current sign-in state; leave unselected when the @@ -883,7 +937,7 @@ export const useCippUserActions = () => { { label: 'Reset Password', type: 'POST', - icon: , + icon: , url: '/api/ExecResetPass', data: { ID: 'userPrincipalName', @@ -905,7 +959,7 @@ export const useCippUserActions = () => { { label: 'Require Password Change at Next Logon', type: 'POST', - icon: , + icon: , url: '/api/ExecRequirePasswordChange', data: { ID: 'id', @@ -918,7 +972,7 @@ export const useCippUserActions = () => { { label: 'Set Password Expiration', type: 'POST', - icon: , + icon: , url: '/api/ExecPasswordNeverExpires', data: { userId: 'id', userPrincipalName: 'userPrincipalName' }, fields: [ @@ -941,7 +995,7 @@ export const useCippUserActions = () => { { label: 'Clear Immutable ID', type: 'POST', - icon: , + icon: , url: '/api/ExecClrImmId', data: { ID: 'id', @@ -954,7 +1008,7 @@ export const useCippUserActions = () => { label: 'Set Source of Authority', type: 'POST', url: '/api/ExecSetCloudManaged', - icon: , + icon: , data: { ID: 'id', displayName: 'displayName', @@ -1013,7 +1067,7 @@ export const useCippUserActions = () => { { label: 'Reprocess License Assignments', type: 'POST', - icon: , + icon: , url: '/api/ExecReprocessUserLicenses', data: { ID: 'id', userPrincipalName: 'userPrincipalName' }, confirmText: @@ -1024,7 +1078,7 @@ export const useCippUserActions = () => { { label: 'Revoke all user sessions', type: 'POST', - icon: , + icon: , url: '/api/ExecRevokeSessions', data: { ID: 'id', Username: 'userPrincipalName' }, confirmText: 'Are you sure you want to revoke all sessions for [userPrincipalName]?', @@ -1034,7 +1088,7 @@ export const useCippUserActions = () => { { label: 'Delete User', type: 'POST', - icon: , + icon: , url: '/api/RemoveUser', data: { ID: 'id', userPrincipalName: 'userPrincipalName' }, confirmText: 'Are you sure you want to delete [userPrincipalName]?', @@ -1043,7 +1097,7 @@ export const useCippUserActions = () => { }, { label: 'Edit Properties', - icon: , + icon: , multiPost: true, noConfirm: true, customFunction: (users, action, formData) => { diff --git a/src/components/CippComponents/CippUserPhotoManager.jsx b/src/components/CippComponents/CippUserPhotoManager.jsx index fe93e2a67d1a..d0ee89d3dd0b 100644 --- a/src/components/CippComponents/CippUserPhotoManager.jsx +++ b/src/components/CippComponents/CippUserPhotoManager.jsx @@ -1,4 +1,5 @@ import React, { useState, useRef } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Box, Button, @@ -12,7 +13,6 @@ import { FormControl, FormLabel, } from "@mui/material"; -import { PhotoCamera, Delete, AccountCircle } from "@mui/icons-material"; import { ApiPostCall } from "../../api/ApiCall"; import PropTypes from "prop-types"; @@ -182,7 +182,7 @@ export const CippUserPhotoManager = ({ borderColor: "divider", }} > - + {/* Hidden file input */} @@ -195,13 +195,15 @@ export const CippUserPhotoManager = ({ /> {/* Action buttons */} - + {!selectedFile ? ( <> diff --git a/src/components/CippComponents/EnrollmentProfileTabs.jsx b/src/components/CippComponents/EnrollmentProfileTabs.jsx index 1288c9a97e74..65af01cc2d90 100644 --- a/src/components/CippComponents/EnrollmentProfileTabs.jsx +++ b/src/components/CippComponents/EnrollmentProfileTabs.jsx @@ -1,4 +1,5 @@ import { useMemo, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Alert, Button, @@ -10,20 +11,8 @@ import { Typography, } from '@mui/material' import { Box, Container, Stack } from '@mui/system' -import { - AccountTree, - Apple, - ContentCopy, - Delete, - EventAvailable, - LaptopChromebook, - LinkOff, - QrCode2, - Sync, -} from '@mui/icons-material' -import { UserGroupIcon } from '@heroicons/react/24/outline' import { CippHead } from './CippHead.jsx' -import { CippDataTable } from '../CippTable/CippDataTable.js' +import { CippDataTable } from '../CippTable/CippDataTable' import { CippInfoBar } from '../CippCards/CippInfoBar.jsx' import { CippApiDialog } from './CippApiDialog.jsx' import { CippAutopilotProfileDrawer } from './CippAutopilotProfileDrawer.jsx' @@ -112,11 +101,17 @@ const AndroidQrDialog = ({ row, drawerVisible, setDrawerVisible }) => { /> )} - + Token value - ) + ); } export const AppleADEEnrollmentProfiles = () => { @@ -199,7 +194,7 @@ export const AppleADEEnrollmentProfiles = () => { const infoBarData = [ { - icon: , + icon: , name: 'ADE Tokens', data: tokens.length, offcanvas: { @@ -225,19 +220,19 @@ export const AppleADEEnrollmentProfiles = () => { }, }, { - icon: , + icon: , name: 'Expiring Tokens', data: expiringTokens.length, color: expiringTokens.length ? 'error' : 'success', toolTip: 'Tokens expiring within 30 days', }, { - icon: , + icon: , name: 'ADE Profiles', data: profiles.length, }, { - icon: , + icon: , name: 'Last Successful Sync', data: lastSuccessfulSync ? new Date(lastSuccessfulSync).toLocaleString() : 'N/A', toolTip: `${totalSyncedDevices} synced devices across all tokens`, @@ -248,7 +243,7 @@ export const AppleADEEnrollmentProfiles = () => { { label: 'Delete Profile', type: 'POST', - icon: , + icon: , url: '/api/ExecRemoveEnrollmentProfile', relatedQueryKeys: [`AppleEnrollmentProfiles*-${currentTenant}`], data: { @@ -333,7 +328,7 @@ export const AppleADEEnrollmentProfiles = () => { }} cardButton={ - @@ -360,7 +355,7 @@ export const AndroidEnterpriseEnrollmentProfiles = () => { const androidActions = [ { label: 'Show QR', - icon: , + icon: , hideBulk: true, noConfirm: true, condition: (row) => Boolean(row?.tokenValue || row?.qrCodeImage?.value || row?.qrCodeContent), @@ -375,7 +370,7 @@ export const AndroidEnterpriseEnrollmentProfiles = () => { { label: 'Delete Profile', type: 'POST', - icon: , + icon: , url: '/api/ExecRemoveEnrollmentProfile', data: { profileId: 'id', @@ -443,7 +438,7 @@ export const WindowsAutopilotEnrollmentProfiles = () => { { label: 'Assign to All Devices', type: 'POST', - icon: , + icon: , url: '/api/ExecAssignAutopilotProfile', data: { ProfileId: 'id', @@ -460,7 +455,7 @@ export const WindowsAutopilotEnrollmentProfiles = () => { { label: 'Assign to Custom Group(s)', type: 'POST', - icon: , + icon: , url: '/api/ExecAssignAutopilotProfile', confirmText: 'Select the target groups for "[displayName]".', color: 'info', @@ -499,7 +494,7 @@ export const WindowsAutopilotEnrollmentProfiles = () => { { label: 'Remove Assignment(s)', type: 'POST', - icon: , + icon: , url: '/api/ExecAssignAutopilotProfile', confirmText: 'Remove assignments from "[displayName]".', color: 'warning', @@ -558,7 +553,7 @@ export const WindowsAutopilotEnrollmentProfiles = () => { }, { label: 'Delete Profile', - icon: , + icon: , type: 'POST', url: '/api/RemoveAutopilotConfig', data: { ID: 'id', displayName: 'displayName', assignments: 'assignments' }, @@ -601,3 +596,4 @@ export const WindowsAutopilotEnrollmentProfiles = () => { ) } + diff --git a/src/components/CippComponents/EnterpriseAppActions.jsx b/src/components/CippComponents/EnterpriseAppActions.jsx index 1d97f6910211..b004f27e5714 100644 --- a/src/components/CippComponents/EnterpriseAppActions.jsx +++ b/src/components/CippComponents/EnterpriseAppActions.jsx @@ -1,22 +1,14 @@ -import { - Launch, - Delete, - Key, - Security, - Block, - CheckCircle, - ContentCopy, - Visibility, -} from '@mui/icons-material' +import { CippIcons } from '../../utils/icon-registry' import { CippFormComponent } from './CippFormComponent.jsx' import { CertificateCredentialRemovalForm } from './CertificateCredentialRemovalForm.jsx' const headerLinkProps = { showInActionsMenu: true } const viewInEntraAction = { - icon: , + icon: , label: 'View Application', link: `https://entra.microsoft.com/[Tenant]/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/[id]/appId/[appId]`, + pinned: true, color: 'info', target: '_blank', multiPost: false, @@ -25,7 +17,7 @@ const viewInEntraAction = { export const getEnterpriseAppPostActions = (canWriteApplication) => [ { - icon: , + icon: , label: 'Create Template from App', type: 'POST', color: 'info', @@ -45,10 +37,11 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [ ], confirmText: "'[displayName]' is a multi-tenant app, so a multi-tenant Enterprise App template will be created. This copies all permissions into a reusable template.", - condition: (row) => canWriteApplication && row?.signInAudience === 'AzureADMultipleOrgs', + condition: (row) => + canWriteApplication && row?.signInAudience === 'AzureADMultipleOrgs', }, { - icon: , + icon: , label: 'Remove Password Credentials', type: 'POST', color: 'warning', @@ -68,7 +61,9 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [ label="Select Password Credentials to Remove" multiple creatable={false} - validators={{ required: 'Please select at least one password credential' }} + validators={{ + required: 'Please select at least one password credential', + }} options={ row?.passwordCredentials?.map((cred) => ({ label: `${cred.displayName || 'Unnamed'} (Expiration: ${new Date( @@ -80,11 +75,13 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [ /> ) }, - confirmText: 'Are you sure you want to remove the selected password credentials?', - condition: (row) => canWriteApplication && row?.passwordCredentials?.length > 0, + confirmText: + 'Are you sure you want to remove the selected password credentials?', + condition: (row) => + canWriteApplication && row?.passwordCredentials?.length > 0, }, { - icon: , + icon: , label: 'Remove Certificate Credentials', type: 'POST', color: 'warning', @@ -98,11 +95,12 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [ children: ({ formHook, row }) => { return }, - confirmText: 'Are you sure you want to remove the selected certificate credentials?', + confirmText: + 'Are you sure you want to remove the selected certificate credentials?', condition: (row) => canWriteApplication && row?.keyCredentials?.length > 0, }, { - icon: , + icon: , label: 'Disable Service Principal', type: 'POST', color: 'warning', @@ -121,7 +119,7 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [ condition: (row) => canWriteApplication && row?.accountEnabled === true, }, { - icon: , + icon: , label: 'Enable Service Principal', type: 'POST', color: 'success', @@ -139,7 +137,40 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [ condition: (row) => canWriteApplication && row?.accountEnabled === false, }, { - icon: , + icon: , + label: 'Hide from MyApps portal', + type: 'POST', + color: 'warning', + multiPost: false, + url: '/api/ExecApplication', + data: { + Id: 'id', + Type: 'servicePrincipals', + Action: 'Hide', + }, + confirmText: + "Hide '[displayName]' from the MyApps portal? Users will no longer see it at myapps.microsoft.com.", + condition: (row) => + canWriteApplication && !(row?.tags ?? []).includes('HideApp'), + }, + { + icon: , + label: 'Show in MyApps portal', + type: 'POST', + color: 'success', + multiPost: false, + url: '/api/ExecApplication', + data: { + Id: 'id', + Type: 'servicePrincipals', + Action: 'Show', + }, + confirmText: "Make '[displayName]' visible to users in the MyApps portal?", + condition: (row) => + canWriteApplication && (row?.tags ?? []).includes('HideApp'), + }, + { + icon: , label: 'Delete Service Principal', type: 'POST', color: 'error', @@ -158,9 +189,10 @@ export const getEnterpriseAppPostActions = (canWriteApplication) => [ export const getEnterpriseAppListActions = (canWriteApplication) => [ { - icon: , + icon: , label: 'View in CIPP', link: '/tenant/administration/applications/enterprise-app?spId=[id]&tenantFilter=[Tenant]', + pinned: true, color: 'info', multiPost: false, external: false, diff --git a/src/components/CippComponents/FailedPaymentDialog.jsx b/src/components/CippComponents/FailedPaymentDialog.jsx index 5668f619c443..9e3820e4bb0d 100644 --- a/src/components/CippComponents/FailedPaymentDialog.jsx +++ b/src/components/CippComponents/FailedPaymentDialog.jsx @@ -26,7 +26,6 @@ export const FailedPaymentDialog = ({ hostedFailedPayments }) => { open={open} maxWidth="sm" fullWidth - disableEscapeKeyDown slotProps={{ backdrop: { onClick: (e) => e.stopPropagation() } }} > Payment Issue diff --git a/src/components/CippComponents/ForcedSsoMigrationDialog.jsx b/src/components/CippComponents/ForcedSsoMigrationDialog.jsx index e5c3c68dc059..8c60479a247e 100644 --- a/src/components/CippComponents/ForcedSsoMigrationDialog.jsx +++ b/src/components/CippComponents/ForcedSsoMigrationDialog.jsx @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Accordion, AccordionDetails, @@ -16,7 +17,6 @@ import { Typography, Button, } from '@mui/material' -import { ExpandMore } from '@mui/icons-material' import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' const SSO_DOCS_URL = 'https://docs.cipp.app/user-documentation/cipp/advanced/authentication/sso' @@ -70,7 +70,6 @@ export const ForcedSsoMigrationDialog = () => { open={open} maxWidth="sm" fullWidth - disableEscapeKeyDown slotProps={{ backdrop: { onClick: (e) => e.stopPropagation() } }} > Complete Authentication Setup @@ -96,7 +95,7 @@ export const ForcedSsoMigrationDialog = () => { just click through, but the ones who have to justify it to a security team need the scopes and the reason for each without leaving the dialog. */} - } sx={{ px: 0 }}> + } sx={{ px: 0 }}> What gets created, and what permissions it asks for @@ -120,7 +119,12 @@ export const ForcedSsoMigrationDialog = () => { list to decide their roles. - + These grant no access to mail, files, Teams or directory data, and the app cannot act without a signed-in user. Everything CIPP does against Microsoft 365 continues to run through the existing CIPP-SAM app. @@ -142,7 +146,12 @@ export const ForcedSsoMigrationDialog = () => { label="Multi-tenant mode (allow users from multiple Entra ID tenants to log in)" sx={{ mb: 1 }} /> - + Leave this off unless the people who sign in to CIPP have accounts in a tenant other than your partner tenant. @@ -172,16 +181,23 @@ export const ForcedSsoMigrationDialog = () => { If it keeps failing - + The usual cause is a policy in your own tenant, not a problem with CIPP. Two to check with your Entra administrator: + sx={{ + color: "text.secondary", + pl: 3, + mb: 2 + }}>
  • An app management policy that blocks adding client secrets. CIPP tries to exempt itself from it; where that is also blocked, an administrator has to @@ -195,7 +211,12 @@ export const ForcedSsoMigrationDialog = () => { {/* This dialog can't be dismissed, so a policy-blocked secret leaves the admin with nowhere to go. Resetting from the management portal drops the instance back to its setup wizard, which is the only route to entering a hand-made app's credentials. */} - + If neither applies, or your administrator has to create the app registration by hand, use Reset SSO in the{' '} { ) : null} - ) + ); } diff --git a/src/components/CippComponents/LegacyRedirectDialog.jsx b/src/components/CippComponents/LegacyRedirectDialog.jsx index 826ed4688cb7..15d47760620f 100644 --- a/src/components/CippComponents/LegacyRedirectDialog.jsx +++ b/src/components/CippComponents/LegacyRedirectDialog.jsx @@ -137,7 +137,9 @@ export const LegacyRedirectDialog = () => { Once done you will need to clear your cache to clear the old redirect from your browser. {currentHost && ( - + Until then, use https://{currentHost} and update any bookmarks. @@ -158,5 +160,5 @@ export const LegacyRedirectDialog = () => { - ) + ); } diff --git a/src/components/CippComponents/LicenseCard.jsx b/src/components/CippComponents/LicenseCard.jsx index b7f1d2218b27..ffaa978ab254 100644 --- a/src/components/CippComponents/LicenseCard.jsx +++ b/src/components/CippComponents/LicenseCard.jsx @@ -1,5 +1,5 @@ import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from "@mui/material"; -import { CardMembership as CardMembershipIcon } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; import { CippSankey } from "./CippSankey"; import { useRouter } from "next/router"; @@ -138,7 +138,7 @@ export const LicenseCard = ({ data, isLoading }) => { "&:hover": { textDecoration: "underline" }, }} > - + License Overview } @@ -163,7 +163,9 @@ export const LicenseCard = ({ data, isLoading }) => { height: "100%", }} > - + No license data available @@ -192,28 +194,40 @@ export const LicenseCard = ({ data, isLoading }) => { ) : data && Array.isArray(data) && data.length > 0 ? ( - + Total Licenses - + {stats.total.toLocaleString()} - + Assigned - + {stats.assigned.toLocaleString()} - + Available - + {stats.available.toLocaleString()} @@ -227,7 +241,9 @@ export const LicenseCard = ({ data, isLoading }) => { py: 2, }} > - + No license statistics available diff --git a/src/components/CippComponents/MFACard.jsx b/src/components/CippComponents/MFACard.jsx index 0a2cc9730fb4..4cb369f5b47c 100644 --- a/src/components/CippComponents/MFACard.jsx +++ b/src/components/CippComponents/MFACard.jsx @@ -1,5 +1,5 @@ import { Box, Card, CardHeader, CardContent, Typography, Skeleton } from "@mui/material"; -import { Person as UserIcon } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; import { CippSankey } from "./CippSankey"; import { useRouter } from "next/router"; @@ -221,7 +221,7 @@ export const MFACard = ({ data, isLoading }) => { "&:hover": { textDecoration: "underline" }, }} > - + User authentication } @@ -247,7 +247,9 @@ export const MFACard = ({ data, isLoading }) => { width: "100%", }} > - + No MFA data available @@ -256,7 +258,9 @@ export const MFACard = ({ data, isLoading }) => { {!isLoading && processedData?.description && ( - + {processedData.description} diff --git a/src/components/CippComponents/MailboxRestoreDetails.jsx b/src/components/CippComponents/MailboxRestoreDetails.jsx index cd3eedff4091..eb25a435657e 100644 --- a/src/components/CippComponents/MailboxRestoreDetails.jsx +++ b/src/components/CippComponents/MailboxRestoreDetails.jsx @@ -1,12 +1,11 @@ import React, { useEffect, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Box, Typography, Dialog, DialogContent, DialogTitle, IconButton } from "@mui/material"; import { CippCodeBlock } from "../CippComponents/CippCodeBlock"; import { ApiGetCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; -import { Close } from "@mui/icons-material"; -import { DocumentTextIcon } from "@heroicons/react/24/outline"; const MailboxRestoreDetails = ({ data }) => { const tenantFilter = useSettings().currentTenant; @@ -83,7 +82,7 @@ const MailboxRestoreDetails = ({ data }) => { onClick={() => setDialogOpen(false)} sx={{ position: "absolute", right: 8, top: 8 }} > - + @@ -108,7 +107,7 @@ const MailboxRestoreDetails = ({ data }) => { label: "View Report", noConfirm: true, customFunction: () => setDialogOpen(true), - icon: , + icon: , }, ]} /> diff --git a/src/components/CippComponents/ScheduledTaskDetails.jsx b/src/components/CippComponents/ScheduledTaskDetails.jsx index 51991b1b88a7..1713631ca796 100644 --- a/src/components/CippComponents/ScheduledTaskDetails.jsx +++ b/src/components/CippComponents/ScheduledTaskDetails.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Box, Typography, @@ -12,17 +13,19 @@ import { Tooltip, Stack, Skeleton, + CircularProgress, } from "@mui/material"; import { ApiGetCall } from "../../api/ApiCall"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; -import { ExpandMore, Sync, Search, Close } from "@mui/icons-material"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { CippDataTable } from "../CippTable/CippDataTable"; import { CippTimeAgo } from "./CippTimeAgo"; import { ActionsMenu } from "../actions-menu"; import { CippScheduledTaskActions } from "./CippScheduledTaskActions"; import { CippApiLogsDrawer } from "./CippApiLogsDrawer"; +import { CippJobProgress, formatJobProgressText, OFFBOARDING_PROGRESS_ACTIONS } from "./CippJobProgress"; +import { CippCopyToClipBoard } from "./CippCopyToClipboard"; const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => { const [taskDetails, setTaskDetails] = useState(null); @@ -33,14 +36,33 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => setExpanded(newExpanded ? panel : false); }; + // Keep the page live while the task is still in flight (Planned, Running or Processing). + const inFlight = ["Planned", "Running", "Processing"].includes(taskDetails?.Task?.TaskState); const taskDetailResults = ApiGetCall({ url: `/api/ListScheduledItemDetails`, data: { RowKey: data.RowKey, }, queryKey: `ListScheduledItemDetails-${data.RowKey}`, + refetchInterval: inFlight ? 5000 : false, }); + // Live step progress for jobs that report it (e.g. user offboarding). A wizard run shares one + // job across its users, so only this task's user is shown. + const deploymentId = taskDetails?.Task?.Parameters?.DeploymentId; + const username = taskDetails?.Task?.Parameters?.Username; + const progressResults = ApiGetCall({ + url: "/api/ListAsyncDeployment", + data: { DeploymentId: deploymentId }, + queryKey: `ListAsyncDeployment-${deploymentId}`, + waiting: !!deploymentId, + refetchInterval: inFlight ? 5000 : false, + staleTime: 0, + }); + const progressRows = (Array.isArray(progressResults.data) ? progressResults.data : []).filter( + (row) => !username || row.Name === username + ); + const taskProperties = [ "TaskState", "Command", @@ -82,12 +104,20 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => return ( <> - - + {/* Title and actions together exceed a phone width, so they stack below sm. */} + + {/* Task names embed UPNs, which are unbreakable tokens wider than a phone. */} + {showTitle && (taskDetailResults.isLoading ? : taskDetails?.Task?.Name)} {showActions && ( - + actionButton={ taskDetailResults.refetch()}> - + } @@ -125,13 +155,60 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => isFetching={taskDetailResults.isFetching} /> + {deploymentId && (inFlight || progressRows.length > 0) && ( + + }> + + Progress + {inFlight && } + {progressRows.length > 0 && ( + e.stopPropagation()}> + + + )} + + + + {progressRows.length === 0 ? ( + + Waiting for the first status update... + + ) : ( + taskDetailResults.refetch()} + actions={ + taskDetails?.Task?.Command === "Invoke-CIPPOffboardingJob" + ? OFFBOARDING_PROGRESS_ACTIONS + : undefined + } + /> + )} + + + )} + + {taskDetails?.Task?.PostExecutionResults?.length > 0 && ( + ({ + label: outcome.Channel || `Delivery ${index + 1}`, + value: outcome.Result || "No response recorded", + }))} + isFetching={taskDetailResults.isFetching} + /> + )} + {taskDetails?.Task?.Trigger && ( - }> + }> Trigger Configuration @@ -150,7 +227,7 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => )} - {taskDetailResults.isFetching ? ( + {taskDetailResults.isLoading ? ( ) : ( <> @@ -160,7 +237,7 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => expanded={expanded === "task-parameters"} onChange={handleChange("task-parameters")} > - }> + }> Task Parameters @@ -183,7 +260,7 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => )} - {taskDetailResults.isFetching ? ( + {taskDetailResults.isLoading ? ( ) : ( <> @@ -191,14 +268,18 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => <> + sx={{ + justifyContent: "space-between", + alignItems: "center", + mt: 4, + mb: 2 + }}> Execution Results{" "} {filteredDetails && ( - + ({filteredDetails.length} of {taskDetails.Details.length}) )} @@ -210,25 +291,27 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => placeholder="Search results..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - endAdornment: searchQuery && ( - - - setSearchQuery("")} - aria-label="Clear search" - > - - - - - ), + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: searchQuery && ( + + + setSearchQuery("")} + aria-label="Clear search" + > + + + + + ), + } }} /> @@ -243,7 +326,7 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => sx={{ mb: 2 }} > } + expandIcon={} sx={{ "& .MuiAccordionSummary-content": { display: "flex", @@ -266,7 +349,9 @@ const ScheduledTaskDetails = ({ data, showActions = true, showTitle = true }) => {result.Results === "null" || !result.Results ? ( - No data available + No data available ) : Array.isArray(result.Results) ? ( textAlign: "center", }} > - + No results match your search criteria diff --git a/src/components/CippComponents/SecureScoreCard.jsx b/src/components/CippComponents/SecureScoreCard.jsx index 6117faa2ed9d..e9d7a7394f93 100644 --- a/src/components/CippComponents/SecureScoreCard.jsx +++ b/src/components/CippComponents/SecureScoreCard.jsx @@ -1,5 +1,6 @@ import { Box, Card, CardHeader, CardContent, Typography, Divider, Skeleton } from '@mui/material' -import { Security as SecurityIcon } from '@mui/icons-material' +import { CippIcons } from '../../utils/icon-registry' +import { useTheme } from '@mui/material/styles' import { useRouter } from 'next/router' import { LineChart, @@ -20,17 +21,18 @@ import { useIsMobileLayout } from '../../hooks/use-breakpoint' * assert against: recharts reads its axis children's props directly rather than mounting them, * so an XAxis cannot be captured by wrapping it. * - * `interval: 0` draws a label for every point. Thirteen dates fit across a desktop card and - * overlap into one smear at 390px — "Jul 27Jul 28Jul 29". A narrow chart hands spacing back to - * recharts and lets it drop whatever will not fit. + * A fixed `interval: 0` drew a label for every point regardless of card width, which overlapped + * into an unreadable run whenever the card was narrower than a full desktop column (e.g. the + * dashboard's third-width layout). `interval="preserveStartEnd"` with a minimum pixel gap lets + * recharts drop whatever labels do not fit, at any width, while a short tickFormatter keeps more + * of them legible before that thinning kicks in. */ -export const secureScoreAxisProps = ({ isMobile, ticks }) => ({ +export const secureScoreAxisProps = ({ isMobile }) => ({ x: { tick: { fontSize: isMobile ? 10 : 12 }, tickMargin: 8, - ticks: isMobile ? undefined : ticks, - interval: isMobile ? 'preserveStartEnd' : 0, - minTickGap: isMobile ? 28 : 5, + interval: 'preserveStartEnd', + minTickGap: isMobile ? 28 : 32, }, y: { tick: { fontSize: isMobile ? 10 : 12 }, @@ -42,6 +44,9 @@ export const secureScoreAxisProps = ({ isMobile, ticks }) => ({ export const SecureScoreCard = ({ data, isLoading }) => { const router = useRouter() const isMobile = useIsMobileLayout() + // recharts has no theme; hard-coded light greys drew a near-white grid and a white tooltip + // over the dark card, so grid, reference line and tooltip come off the palette. + const theme = useTheme() return ( { '&:hover': { textDecoration: 'underline' }, }} > - + Secure Score } @@ -71,7 +76,12 @@ export const SecureScoreCard = ({ data, isLoading }) => { - + The Secure Score measures your security posture across your tenant. @@ -86,12 +96,19 @@ export const SecureScoreCard = ({ data, isLoading }) => { height: '100%', }} > - + No secure score data available - + The Secure Score measures your security posture across your tenant. @@ -113,14 +130,13 @@ export const SecureScoreCard = ({ data, isLoading }) => { score: score.currentScore, percentage: Math.round((score.currentScore / score.maxScore) * 100), })) - const ticks = chartData.map((d) => d.date) - const axis = secureScoreAxisProps({ isMobile, ticks }) + const axis = secureScoreAxisProps({ isMobile }) return ( - + { /> { if (name === 'score') return [value.toFixed(2), 'Score'] @@ -169,7 +184,12 @@ export const SecureScoreCard = ({ data, isLoading }) => { })()} - + The Secure Score measures your security posture across your tenant. @@ -192,16 +212,22 @@ export const SecureScoreCard = ({ data, isLoading }) => { ) : !data || !Array.isArray(data) || data.length === 0 ? ( - + Enable secure score monitoring in your tenant ) : ( - + Latest % - + {Math.round( (data[data.length - 1].currentScore / data[data.length - 1].maxScore) * 100 )} @@ -210,19 +236,27 @@ export const SecureScoreCard = ({ data, isLoading }) => { - + Current Score - + {data[data.length - 1].currentScore.toFixed(2)} - + Max Score - + {data[data.length - 1].maxScore.toFixed(2)} @@ -230,5 +264,5 @@ export const SecureScoreCard = ({ data, isLoading }) => { )} - ) + ); } diff --git a/src/components/CippComponents/SecureScoreChart.jsx b/src/components/CippComponents/SecureScoreChart.jsx deleted file mode 100644 index f9830d128481..000000000000 --- a/src/components/CippComponents/SecureScoreChart.jsx +++ /dev/null @@ -1,153 +0,0 @@ -import { Box, Typography, Divider, Skeleton } from "@mui/material"; -import { - LineChart, - Line, - CartesianGrid, - XAxis, - YAxis, - ResponsiveContainer, - Tooltip as RechartsTooltip, -} from "recharts"; - -export const SecureScoreChart = ({ data, isLoading }) => { - if (isLoading) { - return ( - <> - - - - - - - The Secure Score measures your security posture across your tenant. - - - - - - - - - - - - - - - - - ); - } - - if (!data || !Array.isArray(data) || data.length === 0) { - return ( - <> - - - - No secure score data available - - - - - The Secure Score measures your security posture across your tenant. - - - - - Enable secure score monitoring in your tenant - - - - ); - } - - const sortedData = [...data].sort( - (a, b) => new Date(a.createdDateTime) - new Date(b.createdDateTime) - ); - - const chartData = sortedData.map((score) => ({ - date: new Date(score.createdDateTime).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }), - score: score.currentScore, - percentage: Math.round((score.currentScore / score.maxScore) * 100), - })); - - const latestScore = sortedData[sortedData.length - 1]; - const latestPercentage = Math.round((latestScore.currentScore / latestScore.maxScore) * 100); - - return ( - <> - - - - - - - { - if (name === "score") return [value.toFixed(2), "Score"]; - if (name === "percentage") return [value + "%", "Percentage"]; - return value; - }} - /> - - - - - - The Secure Score measures your security posture across your tenant. - - - - - - Latest % - - - {latestPercentage}% - - - - - - Current Score - - - {latestScore.currentScore.toFixed(2)} - - - - - - Max Score - - - {latestScore.maxScore.toFixed(2)} - - - - - ); -}; diff --git a/src/components/CippComponents/SetupGatePage.jsx b/src/components/CippComponents/SetupGatePage.jsx index 4f1ef230f767..acdd4bdd985f 100644 --- a/src/components/CippComponents/SetupGatePage.jsx +++ b/src/components/CippComponents/SetupGatePage.jsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' +import { useRouter } from 'next/router' import { Alert, Box, Container, Stack, Typography } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import { ApiGetCall } from '../../api/ApiCall' @@ -23,6 +24,7 @@ const purgePersistedCache = () => { // CippAuthShell - its 520px card is far too narrow for the wizard's stepper forms. const SetupGatePage = () => { const queryClient = useQueryClient() + const router = useRouter() useEffect(() => { purgePersistedCache() // The persister already rehydrated pre-setup queries into memory at app start, @@ -51,7 +53,9 @@ const SetupGatePage = () => { > - + Welcome to CIPP @@ -70,13 +74,16 @@ const SetupGatePage = () => { // worker still reports setup incomplete this screen simply stays up, // results intact, and the button doubles as the retry. queryClient.invalidateQueries() + // The gate can be reached on the wizard's own route; land on the dashboard, + // not back on the setup wizard. + if (router.pathname !== '/') router.replace('/') }, }} /> - ) + ); } export default SetupGatePage diff --git a/src/components/CippComponents/SubscriptionEndedDialog.jsx b/src/components/CippComponents/SubscriptionEndedDialog.jsx index e715cce54d4e..f3b6228040bb 100644 --- a/src/components/CippComponents/SubscriptionEndedDialog.jsx +++ b/src/components/CippComponents/SubscriptionEndedDialog.jsx @@ -8,7 +8,6 @@ export const SubscriptionEndedDialog = ({ hostedSubscriptionEnded }) => { open={open} maxWidth="sm" fullWidth - disableEscapeKeyDown slotProps={{ backdrop: { onClick: (e) => e.stopPropagation() } }} > Subscription Ended diff --git a/src/components/CippComponents/TenantInfoCard.jsx b/src/components/CippComponents/TenantInfoCard.jsx index cd4b753e8f2c..319c28052e2e 100644 --- a/src/components/CippComponents/TenantInfoCard.jsx +++ b/src/components/CippComponents/TenantInfoCard.jsx @@ -1,5 +1,5 @@ import { Box, Card, CardHeader, CardContent, Typography, Skeleton } from "@mui/material"; -import { Business as BuildingIcon } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; import { CippCopyToClipBoard } from "./CippCopyToClipboard"; export const TenantInfoCard = ({ data, isLoading }) => { @@ -8,7 +8,7 @@ export const TenantInfoCard = ({ data, isLoading }) => { - + Tenant } @@ -17,19 +17,25 @@ export const TenantInfoCard = ({ data, isLoading }) => { - + Name {isLoading ? ( ) : ( - + {data?.displayName || "Not Available"} )} - + Tenant ID @@ -38,14 +44,18 @@ export const TenantInfoCard = ({ data, isLoading }) => { ) : data?.id ? ( ) : ( - + Not Available )} - + Primary Domain @@ -57,7 +67,9 @@ export const TenantInfoCard = ({ data, isLoading }) => { type="chip" /> ) : ( - + Not Available )} diff --git a/src/components/CippComponents/TenantMetricsGrid.jsx b/src/components/CippComponents/TenantMetricsGrid.jsx index 404f1b6601b7..c4652acbb0c5 100644 --- a/src/components/CippComponents/TenantMetricsGrid.jsx +++ b/src/components/CippComponents/TenantMetricsGrid.jsx @@ -1,13 +1,6 @@ import { Box, Grid, Tooltip, Avatar, Typography, Skeleton } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' import { useRouter } from 'next/router' -import { - Person as UserIcon, - PersonOutline as GuestIcon, - Group as GroupIcon, - Apps as AppsIcon, - Devices as DevicesIcon, - PhoneAndroid as ManagedIcon, -} from '@mui/icons-material' const formatNumber = (num) => { if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M' @@ -22,42 +15,42 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { { label: 'Users', value: data?.UserCount || 0, - icon: UserIcon, + icon: CippIcons.Person, color: 'primary', path: '/identity/administration/users', }, { label: 'Guests', value: data?.GuestCount || 0, - icon: GuestIcon, + icon: CippIcons.PersonOutlined, color: 'info', path: '/identity/administration/users', }, { label: 'Groups', value: data?.GroupCount || 0, - icon: GroupIcon, + icon: CippIcons.Group, color: 'secondary', path: '/identity/administration/groups', }, { label: 'Service Principals', value: data?.ApplicationCount || 0, - icon: AppsIcon, + icon: CippIcons.Apps, color: 'error', path: '/tenant/administration/applications/enterprise-apps', }, { label: 'Devices', value: data?.DeviceCount || 0, - icon: DevicesIcon, + icon: CippIcons.Devices, color: 'warning', path: '/identity/administration/devices', }, { label: 'Managed', value: data?.ManagedDeviceCount || 0, - icon: ManagedIcon, + icon: CippIcons.PhoneAndroid, color: 'success', path: '/identity/administration/devices', }, @@ -122,15 +115,18 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { + sx={{ + color: "text.secondary", + fontSize: { xs: '0.6rem', sm: '0.65rem', md: '0.7rem' } + }}> {metric.label} {isLoading ? ( @@ -142,8 +138,8 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { - ) + ); })} - ) + ); } diff --git a/src/components/CippFormPages/CippAddEditGdapRoleTemplate.jsx b/src/components/CippFormPages/CippAddEditGdapRoleTemplate.jsx index 891935f0f99a..d0638dfe83dc 100644 --- a/src/components/CippFormPages/CippAddEditGdapRoleTemplate.jsx +++ b/src/components/CippFormPages/CippAddEditGdapRoleTemplate.jsx @@ -1,17 +1,86 @@ -import { Alert, Box, Stack, Typography } from "@mui/material"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, + Chip, + Link, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; +import { useMemo, useState } from "react"; +import { useWatch } from "react-hook-form"; import CippFormComponent from "../CippComponents/CippFormComponent"; +import { CippPropertyList } from "../CippComponents/CippPropertyList"; +import GDAPRoles from "../../data/GDAPRoles"; +import cippDefaults from "../../data/CIPPDefaultGDAPRoles"; +import { + GLOBAL_ADMIN_ROLE_ID, + buildGdapRoleOptions, + gdapPreviewStatus, + gdapSelectionRoleId, + resolveGdapSelections, + selectGdapDefaultOptions, +} from "../../utils/gdap-role-options"; export const CippAddEditGdapRoleTemplate = (props) => { - const { formControl, availableRoles } = props; + const { formControl, availableRoles, extraOptions, mixedSuffixes } = props; + const [advancedOpen, setAdvancedOpen] = useState(false); + + const selectedRoles = useWatch({ control: formControl.control, name: "roleMappings" }); + const customSuffix = useWatch({ control: formControl.control, name: "customSuffix" }); + const mappings = availableRoles?.data; + + // The suffix decides which group each role targets, so both the option list and its + // mapped/unmapped headings have to be rebuilt when it changes. + const roleOptions = useMemo(() => { + const options = buildGdapRoleOptions(GDAPRoles, mappings, customSuffix); + const known = new Set(options.map((option) => option.value)); + return [...options, ...(extraOptions ?? []).filter((option) => !known.has(option.value))]; + }, [mappings, customSuffix, extraOptions]); + + const resolved = useMemo( + () => resolveGdapSelections(selectedRoles, mappings, customSuffix), + [selectedRoles, mappings, customSuffix] + ); + + const handleDefaults = () => { + formControl.setValue("roleMappings", selectGdapDefaultOptions(roleOptions, cippDefaults), { + shouldDirty: true, + }); + formControl.trigger(); + }; + + const globalAdminSelected = (selectedRoles ?? []).some( + (option) => gdapSelectionRoleId(option) === GLOBAL_ADMIN_ROLE_ID + ); return ( - GDAP Role templates are used to create new GDAP invites for your customer tenants. Make - sure to select a template that matches the permissions you want to grant. + A role template is the set of admin roles a GDAP invite grants. Pick the roles you need - + CIPP maps each one to a security group in your partner tenant, creating the group if it + does not exist yet. Add your technicians to those groups to give them the access. + + Certain roles may not be compatible with GDAP. See the{" "} + + Microsoft Documentation + {" "} + on GDAP Role Guidance. + + {mixedSuffixes && ( + + This template mixes group naming, so the roles a suffix cannot reproduce were loaded as + fixed groups. They keep the group they already use. + + )} { required={true} /> - ({ - label: role.GroupName, - value: role.GroupId, - addedFields: role, - }))} - multiple={true} - creatable={false} - required={true} - validators={{ - validate: (value) => { - if (!value || value.length === 0) { - return "Please select at least one GDAP Role Mapping"; - } - return true; - }, - }} - sortOptions={true} - /> + + + + + + + option.group} + multiple={true} + creatable={false} + required={true} + validators={{ + validate: (value) => { + if (!value || value.length === 0) { + return "Please select at least one admin role"; + } + return true; + }, + }} + /> + {customSuffix && !advancedOpen && ( + + Suffix: {customSuffix} + + )} + + + {globalAdminSelected && ( + + The Global Administrator role is a highly privileged role that should be used with + caution. GDAP Relationships with this role will not be eligible for auto-extend. + + )} + + {resolved.length > 0 && ( + + + Group mappings + + { + const status = gdapPreviewStatus(item); + return { + label: item.option.label, + value: ( + + {item.groupName} + + + ), + }; + })} + /> + + )} + + setAdvancedOpen(expanded)} + > + }>Advanced + + + + The suffix changes which group each selected role uses: `M365 GDAP RoleName - Suffix`. + Existing groups with that name are reused, missing ones are created. Use it to keep + separate groups per team. + + + + + ); }; diff --git a/src/components/CippFormPages/CippAddEditUser.jsx b/src/components/CippFormPages/CippAddEditUser.jsx index 3b7290e47d6e..58d6e30e361a 100644 --- a/src/components/CippFormPages/CippAddEditUser.jsx +++ b/src/components/CippFormPages/CippAddEditUser.jsx @@ -1,4 +1,5 @@ import { Alert, Divider, InputAdornment, Typography } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' import CippFormComponent from '../CippComponents/CippFormComponent' import { getCippValidator } from '../../utils/get-cipp-validator' import { toAutoCompleteOptions } from '../../utils/to-autocomplete-options' @@ -14,7 +15,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useWatch } from 'react-hook-form' import { useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/router' -import { Sync } from '@mui/icons-material' // Exchange only sends a sharing invitation for these calendar access levels. const sharedCalendarPermissionOptions = [ @@ -26,6 +26,7 @@ const sharedCalendarPermissionOptions = [ const sharedMailboxPermissionOptions = [ { label: 'Full Access', value: 'FullAccess' }, + { label: 'Full Access (no Automapping)', value: 'FullAccessNoAutoMap' }, { label: 'Send As', value: 'SendAs' }, { label: 'Send on Behalf', value: 'SendOnBehalf' }, ] @@ -34,7 +35,7 @@ const sharedMailboxPermissionOptions = [ const sharedMailboxApi = (tenantDomain) => ({ queryKey: `SharedMailboxes-${tenantDomain}`, url: '/api/ListMailboxes', - data: { RecipientTypeDetails: 'SharedMailbox' }, + data: { RecipientTypeDetails: 'SharedMailbox', Minimal: true }, labelField: (option) => `${option.displayName} (${option.UPN})`, valueField: 'UPN', }) @@ -102,9 +103,31 @@ const CippAddEditUser = (props) => { // Prefill manual entry custom data fields in edit mode. The fetched user's extension values sit // at the top level of the form (edit.jsx resets with the spread user object), while these fields // live under customData.* - const currentUserObjectId = useWatch({ control: formControl.control, name: 'id' }) + const currentUserObjectId = useWatch({ + control: formControl.control, + name: 'id', + }) + // Watched so a later form reset (the user query refetches after a save and on mount) re-seeds + // the customData.* fields: reset wipes them, and without this the effect would not run again + // because the user id did not change, leaving a saved value showing as unset. + const manualAttributeNames = useMemo( + () => + currentTenantManualMappings + .map((mapping) => mapping.customDataAttribute?.value) + .filter(Boolean), + [currentTenantManualMappings] + ) + const manualAttributeValues = useWatch({ + control: formControl.control, + name: manualAttributeNames, + }) + const manualAttributeValuesKey = JSON.stringify(manualAttributeValues ?? []) useEffect(() => { - if (formType === 'add' || !currentUserObjectId || currentTenantManualMappings.length === 0) + if ( + formType === 'add' || + !currentUserObjectId || + currentTenantManualMappings.length === 0 + ) return currentTenantManualMappings.forEach((mapping) => { const attribute = mapping.customDataAttribute?.value @@ -116,7 +139,12 @@ const CippAddEditUser = (props) => { formControl.setValue(`customData.${attribute}`, value) } }) - }, [formType, currentUserObjectId, currentTenantManualMappings]) + }, [ + formType, + currentUserObjectId, + currentTenantManualMappings, + manualAttributeValuesKey, + ]) // Make new list of groups by removing userGroups from tenantGroups const filteredTenantGroups = useMemo(() => { @@ -124,11 +152,19 @@ const CippAddEditUser = (props) => { const tenantGroupsList = tenantGroups?.data || [] return tenantGroupsList.filter( - (tenantGroup) => !userGroups?.data?.some((userGroup) => userGroup.id === tenantGroup.id) + (tenantGroup) => + !userGroups?.data?.some( + (userGroup) => userGroup.id === tenantGroup.id + ) ) } return [] - }, [tenantGroups.isSuccess, userGroups.isSuccess, tenantGroups.data, userGroups.data]) + }, [ + tenantGroups.isSuccess, + userGroups.isSuccess, + tenantGroups.data, + userGroups.data, + ]) const watcher = useWatch({ control: formControl.control, @@ -165,25 +201,41 @@ const CippAddEditUser = (props) => { // checking one page would miss most of the tenant. Warning-only: the cache can be partial or // stale, so no conflict found is never presented as the name being available. const queryClient = useQueryClient() - const usernameValue = useWatch({ control: formControl.control, name: 'username' }) - const primDomainValue = useWatch({ control: formControl.control, name: 'primDomain' }) + const usernameValue = useWatch({ + control: formControl.control, + name: 'username', + }) + const primDomainValue = useWatch({ + control: formControl.control, + name: 'primDomain', + }) const usernameConflict = useMemo(() => { - if (formType !== 'add' || !usernameValue || !primDomainValue?.value) return null + if (formType !== 'add' || !usernameValue || !primDomainValue?.value) + return null const cachedUsers = queryClient .getQueryData([`Users - ${tenantDomain}`]) ?.pages?.flatMap((page) => page?.Results ?? []) if (!cachedUsers?.length) return null - const candidateUPN = `${usernameValue}@${primDomainValue.value}`.toLowerCase() + const candidateUPN = + `${usernameValue}@${primDomainValue.value}`.toLowerCase() const candidateSmtp = `smtp:${candidateUPN}` return ( cachedUsers.find( (user) => user?.userPrincipalName?.toLowerCase() === candidateUPN || (Array.isArray(user?.proxyAddresses) && - user.proxyAddresses.some((address) => address?.toLowerCase() === candidateSmtp)) + user.proxyAddresses.some( + (address) => address?.toLowerCase() === candidateSmtp + )) ) ?? null ) - }, [formType, usernameValue, primDomainValue?.value, tenantDomain, queryClient]) + }, [ + formType, + usernameValue, + primDomainValue?.value, + tenantDomain, + queryClient, + ]) // Helper function to generate username from template format const generateUsername = ( @@ -206,7 +258,7 @@ const CippAddEditUser = (props) => { return firstName .split(/\s+/) .map((word) => word.substring(0, n)) - .join('') + .join(''); }) // Replace %LastName[n]% patterns (extract first n characters per word) @@ -215,7 +267,7 @@ const CippAddEditUser = (props) => { return lastName .split(/\s+/) .map((word) => word.substring(0, n)) - .join('') + .join(''); }) // Replace %FirstName% and %LastName% @@ -243,14 +295,20 @@ const CippAddEditUser = (props) => { .map((email) => email.trim()) .filter(Boolean) - const invalidEmail = emailList.find((email) => getCippValidator(email, 'email') !== true) + const invalidEmail = emailList.find( + (email) => getCippValidator(email, 'email') !== true + ) return !invalidEmail || `This is not a valid email: ${invalidEmail}` } useEffect(() => { //if watch.firstname changes, and watch.lastname changes, set displayname to firstname + lastname - if (watchedFields.givenName && watchedFields.surname && formType === 'add') { + if ( + watchedFields.givenName && + watchedFields.surname && + formType === 'add' + ) { // Only auto-set display name if user hasn't manually changed it if (!displayNameManuallySet) { // Build base display name from first and last name @@ -270,7 +328,8 @@ const CippAddEditUser = (props) => { const formatString = typeof selectedTemplate.usernameFormat === 'string' ? selectedTemplate.usernameFormat - : selectedTemplate.usernameFormat?.value || selectedTemplate.usernameFormat?.label + : selectedTemplate.usernameFormat?.value || + selectedTemplate.usernameFormat?.label if (formatString) { const spaceHandling = @@ -295,7 +354,9 @@ const CippAddEditUser = (props) => { spaceReplacement ) if (generatedUsername) { - formControl.setValue('username', generatedUsername, { shouldDirty: true }) + formControl.setValue('username', generatedUsername, { + shouldDirty: true, + }) } } } @@ -328,7 +389,11 @@ const CippAddEditUser = (props) => { // Auto-select default template for tenant useEffect(() => { - if (formType === 'add' && userTemplates.isSuccess && !watchedFields.userTemplate) { + if ( + formType === 'add' && + userTemplates.isSuccess && + !watchedFields.userTemplate + ) { const defaultTemplate = userTemplates.data?.find( (template) => template.defaultForTenant === true ) @@ -347,7 +412,8 @@ const CippAddEditUser = (props) => { useEffect(() => { if (formType !== 'add' || !watchedFields.userTemplate?.addedFields) return const template = watchedFields.userTemplate.addedFields - const templateKey = watchedFields.userTemplate.value ?? template.GUID ?? template.templateName + const templateKey = + watchedFields.userTemplate.value ?? template.GUID ?? template.templateName // Apply a template once per selection. useWatch hands back a freshly cloned userTemplate // whenever any other watched field changes - AddToGroups included - so without this guard the @@ -415,11 +481,16 @@ const CippAddEditUser = (props) => { applyField('companyName', template.companyName) applyField('department', template.department) applyField('mobilePhone', template.mobilePhone) + applyField('perUserMfa', template.perUserMfa === true, false) const templateBusinessPhone = Array.isArray(template.businessPhones) ? template.businessPhones[0] : template.businessPhones - applyField('businessPhones', templateBusinessPhone ? [templateBusinessPhone] : [], []) + applyField( + 'businessPhones', + templateBusinessPhone ? [templateBusinessPhone] : [], + [] + ) // Licenses - match the format expected by CippFormLicenseSelector applyField( @@ -445,7 +516,7 @@ const CippAddEditUser = (props) => { : 'Distribution list' : 'Security' return { - label: g.displayName, + label: g.mail ? `${g.displayName} - ${g.mail}` : g.displayName, value: g.id, addedFields: { groupType }, } @@ -454,13 +525,24 @@ const CippAddEditUser = (props) => { // Shared mailbox/calendar selections may be stored as option objects or as bare values // depending on when the template was saved, so normalise before handing them to the fields. - applyField('sharedMailboxes', toAutoCompleteOptions(template.sharedMailboxes), []) + applyField( + 'sharedMailboxes', + toAutoCompleteOptions(template.sharedMailboxes), + [] + ) applyField( 'sharedMailboxPermission', - toAutoCompleteOptions(template.sharedMailboxPermission, sharedMailboxPermissionOptions), + toAutoCompleteOptions( + template.sharedMailboxPermission, + sharedMailboxPermissionOptions + ), + [] + ) + applyField( + 'sharedCalendars', + toAutoCompleteOptions(template.sharedCalendars), [] ) - applyField('sharedCalendars', toAutoCompleteOptions(template.sharedCalendars), []) applyField( 'sharedCalendarPermission', toAutoCompleteOptions( @@ -477,9 +559,13 @@ const CippAddEditUser = (props) => { userSettingsDefaults?.userAttributes ?.filter((attribute) => attribute.value !== 'sponsor') .forEach((attribute) => { - formControl.setValue(`defaultAttributes.${attribute.label}.Value`, '', { - shouldDirty: true, - }) + formControl.setValue( + `defaultAttributes.${attribute.label}.Value`, + '', + { + shouldDirty: true, + } + ) }) } if (template.defaultAttributes) { @@ -541,7 +627,7 @@ const CippAddEditUser = (props) => { : [] } customAction={{ - icon: , + icon: , tooltip: 'Refresh templates', onClick: () => { userTemplates.refetch() @@ -562,7 +648,10 @@ const CippAddEditUser = (props) => { name="givenName" formControl={formControl} validators={{ - maxLength: { value: 64, message: 'First Name cannot exceed 64 characters' }, + maxLength: { + value: 64, + message: 'First Name cannot exceed 64 characters', + }, }} /> @@ -574,7 +663,10 @@ const CippAddEditUser = (props) => { name="surname" formControl={formControl} validators={{ - maxLength: { value: 64, message: 'Last Name cannot exceed 64 characters' }, + maxLength: { + value: 64, + message: 'Last Name cannot exceed 64 characters', + }, }} /> @@ -587,7 +679,10 @@ const CippAddEditUser = (props) => { formControl={formControl} validators={{ required: 'Display Name is required', - maxLength: { value: 256, message: 'Display Name cannot exceed 256 characters' }, + maxLength: { + value: 256, + message: 'Display Name cannot exceed 256 characters', + }, }} onChange={(e) => { setDisplayNameManuallySet(true) @@ -600,17 +695,19 @@ const CippAddEditUser = (props) => { type="textField" fullWidth label="Username" - InputProps={{ - endAdornment: @, - }} + slotProps={{ input: { endAdornment: @ } }} name="username" formControl={formControl} validators={{ required: 'Username is required', - maxLength: { value: 64, message: 'Username cannot exceed 64 characters' }, + maxLength: { + value: 64, + message: 'Username cannot exceed 64 characters', + }, pattern: { value: /^[A-Za-z0-9'.\-_!#^~]+$/, - message: "Username can only contain letters, numbers, and ' . - _ ! # ^ ~ characters", + message: + "Username can only contain letters, numbers, and ' . - _ ! # ^ ~ characters", }, }} onChange={(e) => { @@ -673,7 +770,8 @@ const CippAddEditUser = (props) => { formControl={formControl} validators={{ validate: (value) => { - const isManualPasswordEnabled = formControl.getValues('Autopassword') + const isManualPasswordEnabled = + formControl.getValues('Autopassword') if (!isManualPasswordEnabled) { return true } @@ -692,6 +790,16 @@ const CippAddEditUser = (props) => { formControl={formControl} /> + {formType === 'add' && ( + + + + )} { > - This will Purchase a new Sherweb License for the user, according to the terms and - conditions with Sherweb. When the license becomes available, CIPP will assign the - license to this user. + This will Purchase a new Sherweb License for the user, + according to the terms and conditions with Sherweb. When the + license becomes available, CIPP will assign the license to + this user. @@ -751,7 +860,8 @@ const CippAddEditUser = (props) => { queryKey: `SKU-${tenantDomain}`, url: '/api/ListCSPsku', data: { currentSkuOnly: true }, - labelField: (option) => `${option?.productName} (${option?.sku})`, + labelField: (option) => + `${option?.productName} (${option?.sku})`, valueField: 'sku', }} label="Sherweb License" @@ -779,7 +889,10 @@ const CippAddEditUser = (props) => { name="jobTitle" formControl={formControl} validators={{ - maxLength: { value: 128, message: 'Job Title cannot exceed 128 characters' }, + maxLength: { + value: 128, + message: 'Job Title cannot exceed 128 characters', + }, }} /> @@ -791,7 +904,10 @@ const CippAddEditUser = (props) => { name="streetAddress" formControl={formControl} validators={{ - maxLength: { value: 1024, message: 'Street Address cannot exceed 1024 characters' }, + maxLength: { + value: 1024, + message: 'Street Address cannot exceed 1024 characters', + }, }} /> @@ -802,7 +918,12 @@ const CippAddEditUser = (props) => { label="City" name="city" formControl={formControl} - validators={{ maxLength: { value: 128, message: 'City cannot exceed 128 characters' } }} + validators={{ + maxLength: { + value: 128, + message: 'City cannot exceed 128 characters', + }, + }} /> @@ -813,7 +934,10 @@ const CippAddEditUser = (props) => { name="state" formControl={formControl} validators={{ - maxLength: { value: 128, message: 'State/Province cannot exceed 128 characters' }, + maxLength: { + value: 128, + message: 'State/Province cannot exceed 128 characters', + }, }} /> @@ -825,7 +949,10 @@ const CippAddEditUser = (props) => { name="postalCode" formControl={formControl} validators={{ - maxLength: { value: 40, message: 'Postal Code cannot exceed 40 characters' }, + maxLength: { + value: 40, + message: 'Postal Code cannot exceed 40 characters', + }, }} /> @@ -846,7 +973,10 @@ const CippAddEditUser = (props) => { name="companyName" formControl={formControl} validators={{ - maxLength: { value: 64, message: 'Company Name cannot exceed 64 characters' }, + maxLength: { + value: 64, + message: 'Company Name cannot exceed 64 characters', + }, }} /> @@ -858,7 +988,10 @@ const CippAddEditUser = (props) => { name="department" formControl={formControl} validators={{ - maxLength: { value: 64, message: 'Department cannot exceed 64 characters' }, + maxLength: { + value: 64, + message: 'Department cannot exceed 64 characters', + }, }} /> @@ -869,7 +1002,12 @@ const CippAddEditUser = (props) => { label="Mobile #" name="mobilePhone" formControl={formControl} - validators={{ maxLength: { value: 64, message: 'Mobile # cannot exceed 64 characters' } }} + validators={{ + maxLength: { + value: 64, + message: 'Mobile # cannot exceed 64 characters', + }, + }} /> @@ -919,7 +1057,9 @@ const CippAddEditUser = (props) => { showRefresh={true} /> - {userSettingsDefaults?.userAttributes?.some((attribute) => attribute.value === 'sponsor') && ( + {userSettingsDefaults?.userAttributes?.some( + (attribute) => attribute.value === 'sponsor' + ) && ( { name="AddToGroups" multiple={true} options={ - (formType === 'edit' ? filteredTenantGroups : tenantGroups?.data)?.map((group) => ({ - label: group.displayName, + (formType === 'edit' + ? filteredTenantGroups + : tenantGroups?.data + )?.map((group) => ({ + label: group.mail ? `${group.displayName} - ${group.mail}` : group.displayName, value: group.id, addedFields: { groupType: group.groupType, @@ -966,7 +1109,7 @@ const CippAddEditUser = (props) => { creatable={false} formControl={formControl} customAction={{ - icon: , + icon: , tooltip: 'Refresh groups', onClick: () => { tenantGroups.refetch() @@ -1048,7 +1191,7 @@ const CippAddEditUser = (props) => { creatable={false} formControl={formControl} customAction={{ - icon: , + icon: , tooltip: 'Refresh groups', onClick: () => { tenantGroups.refetch() @@ -1109,7 +1252,11 @@ const CippAddEditUser = (props) => { @@ -1120,8 +1267,16 @@ const CippAddEditUser = (props) => { compareValue={true} > - - + + { formControl={formControl} /> + {integrationSettings?.data?.HaloPSA?.Enabled === true && ( + + {/* These Grids sit inside a plain Grid item, not the form's spacing={2} container, + so the gap has to be set here or the box butts against Reference. */} + + + + + )} - ) + ); } export default CippAddEditUser diff --git a/src/components/CippFormPages/CippAddGroupForm.jsx b/src/components/CippFormPages/CippAddGroupForm.jsx index 7463724f8471..be71a7f2be6a 100644 --- a/src/components/CippFormPages/CippAddGroupForm.jsx +++ b/src/components/CippFormPages/CippAddGroupForm.jsx @@ -51,9 +51,7 @@ const CippAddGroupForm = (props) => { name="username" formControl={formControl} fullWidth - InputProps={{ - endAdornment: @, - }} + slotProps={{ input: { endAdornment: @ } }} /> diff --git a/src/components/CippFormPages/CippAddRoomListForm.jsx b/src/components/CippFormPages/CippAddRoomListForm.jsx index 958244dd0b44..802936c5e5c5 100644 --- a/src/components/CippFormPages/CippAddRoomListForm.jsx +++ b/src/components/CippFormPages/CippAddRoomListForm.jsx @@ -7,7 +7,7 @@ const CippAddRoomListForm = ({ formControl }) => { return ( - + { message: "Username can only contain letters, numbers, hyphens, underscores, and periods" } }} - InputProps={{ - endAdornment: @, - }} + slotProps={{ input: { endAdornment: @ } }} /> @@ -46,7 +44,6 @@ const CippAddRoomListForm = ({ formControl }) => { /> - ); }; diff --git a/src/components/CippFormPages/CippExchangeSettingsForm.jsx b/src/components/CippFormPages/CippExchangeSettingsForm.jsx index 812e28c84970..5d665383cf02 100644 --- a/src/components/CippFormPages/CippExchangeSettingsForm.jsx +++ b/src/components/CippFormPages/CippExchangeSettingsForm.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Box, Button, @@ -12,7 +13,6 @@ import { CircularProgress, IconButton, } 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"; @@ -20,7 +20,6 @@ import { useSettings } from "../../hooks/use-settings"; import { Grid } from "@mui/system"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { useWatch } from "react-hook-form"; -import { ChevronDownIcon } from "@heroicons/react/24/outline"; import CippForwardingSection from "../CippComponents/CippForwardingSection"; const CippExchangeSettingsForm = (props) => { @@ -167,9 +166,9 @@ const CippExchangeSettingsForm = (props) => { cardLabelBoxHeader: isFetching ? ( ) : currentSettings?.ForwardingAddress ? ( - + ) : ( - + ), }, text: "Mailbox Forwarding", @@ -427,7 +426,9 @@ const CippExchangeSettingsForm = (props) => { onClick={() => handleExpand(section.id)} > {/* Left Side: cardLabelBox, text, subtext */} - + {/* cardLabelBox */} { - + {section.action && ( @@ -481,7 +484,7 @@ const CippExchangeSettingsForm = (props) => { }, }} > - + @@ -494,7 +497,7 @@ const CippExchangeSettingsForm = (props) => { transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)", }} > - + diff --git a/src/components/CippFormPages/CippFormPage.jsx b/src/components/CippFormPages/CippFormPage.jsx index 102c48e3a28a..ea65aab51c32 100644 --- a/src/components/CippFormPages/CippFormPage.jsx +++ b/src/components/CippFormPages/CippFormPage.jsx @@ -1,4 +1,5 @@ import { useRouter } from 'next/router' +import { CippIcons } from '../../utils/icon-registry' import { useTabNavigation, useTitleClaimedByTabPicker } from '../../layouts/tab-navigation-context' import { Box, @@ -11,7 +12,6 @@ import { CardContent, CardActions, } from '@mui/material' -import ArrowLeftIcon from '@mui/icons-material/ArrowLeft' import { ApiPostCall } from '../../api/ApiCall' import { CippApiResults } from '../CippComponents/CippApiResults' import { createContext, useContext, useEffect } from 'react' @@ -155,11 +155,13 @@ const CippFormPage = (props) => { + sx={{ + justifyContent: "space-between", + alignItems: { xs: 'stretch', sm: 'center' }, + columnGap: 2, + rowGap: 1 + }}> {!hidePageType && <>{formPageType} - } {title} @@ -205,7 +207,7 @@ const CippFormPage = (props) => { - ) + ); } export default CippFormPage diff --git a/src/components/CippFormPages/CippJSONView.jsx b/src/components/CippFormPages/CippJSONView.jsx index 1372d5ef9193..d0657c502ebd 100644 --- a/src/components/CippFormPages/CippJSONView.jsx +++ b/src/components/CippFormPages/CippJSONView.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Accordion, AccordionSummary, @@ -13,10 +14,6 @@ import { Stack, } from '@mui/material' import { Grid } from '@mui/system' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' -import VisibilityIcon from '@mui/icons-material/Visibility' -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' import { PropertyListItem } from '../property-list-item' import { PropertyList } from '../property-list' import { getCippTranslation } from '../../utils/get-cipp-translation' @@ -395,7 +392,7 @@ function CippJsonView({ sx={{ p: 0.25, color: 'text.secondary' }} onClick={(event) => event.stopPropagation()} > - + @@ -525,12 +522,16 @@ function CippJsonView({ {getStatusText(value.enabled)} {categoryPath && ( - + {categoryPath} )} {!definition && definitionId && ( - + Definition ID: {definitionId} )} @@ -744,7 +745,9 @@ function CippJsonView({ key="added-loading" label="Administrative Template" value={ - + Resolving administrative template settings... @@ -1013,10 +1016,16 @@ function CippJsonView({ onChange={() => setAccordionOpen(!accordionOpen)} > } + expandIcon={} sx={{ display: 'flex', alignItems: 'center' }} > - + {title} @@ -1029,7 +1038,7 @@ function CippJsonView({ - {viewJson ? : } + {viewJson ? : } {viewJson ? ( @@ -1077,7 +1086,7 @@ function CippJsonView({ )} - ) + ); } export default CippJsonView diff --git a/src/components/CippFormPages/CippPIMRoleSettingsTemplateForm.jsx b/src/components/CippFormPages/CippPIMRoleSettingsTemplateForm.jsx new file mode 100644 index 000000000000..7557238f1038 --- /dev/null +++ b/src/components/CippFormPages/CippPIMRoleSettingsTemplateForm.jsx @@ -0,0 +1,392 @@ +import { Alert, Divider, Typography } from '@mui/material' +import { Grid } from '@mui/system' +import { useWatch } from 'react-hook-form' +import CippFormComponent from '../CippComponents/CippFormComponent' +import { CippFormCondition } from '../CippComponents/CippFormCondition' +import jitAdminRoles from '../../data/JitAdminRoles.json' + +// Option lists double as the client-side floor: nothing above PT24H (activation) or P365D +// (eligibility / active assignment) is offered, and the backend refuses anything that slips past. +export const activationDurationOptions = [ + { label: '30 minutes', value: 'PT30M' }, + { label: '1 hour', value: 'PT1H' }, + { label: '2 hours', value: 'PT2H' }, + { label: '4 hours', value: 'PT4H' }, + { label: '8 hours (recommended maximum)', value: 'PT8H' }, + { label: '12 hours (above recommended)', value: 'PT12H' }, + { label: '24 hours (hard cap)', value: 'PT24H' }, +] + +export const eligibilityDurationOptions = [ + { label: '3 months', value: 'P90D' }, + { label: '6 months', value: 'P180D' }, + { label: '1 year (maximum)', value: 'P365D' }, +] + +export const activeAssignmentDurationOptions = [ + { label: '1 month', value: 'P30D' }, + { label: '3 months', value: 'P90D' }, + { label: '6 months', value: 'P180D' }, + { label: '1 year (maximum)', value: 'P365D' }, +] + +export const roleScopeOptions = [ + { + label: + 'Privileged roles (CIPP list: Global, Security, Exchange, ... administrators)', + value: 'PrivilegedRoles', + }, + { label: 'All directory roles', value: 'AllRoles' }, + { label: 'Custom selection', value: 'Custom' }, +] + +export const activationRequiresOptions = [ + { label: 'Multi-factor authentication', value: 'MFA' }, + { + label: 'Conditional Access authentication context', + value: 'AuthenticationContext', + }, +] + +export const notificationLevelOptions = [ + { label: 'All notifications', value: 'All' }, + { label: 'Critical notifications only', value: 'Critical' }, +] + +export const roleOptions = [...jitAdminRoles] + .sort((a, b) => a.Name.localeCompare(b.Name)) + .map((role) => ({ label: role.Name, value: role.ObjectId })) + +const findOption = (options, value) => + options.find((option) => option.value === value) ?? + (value ? { label: value, value } : null) + +/** + * Maps a stored template (or nothing, for the secure defaults) onto the form's values. + * Durations are stored as ISO 8601 strings and edited as autocomplete options. + */ +export const templateToFormValues = (template) => { + const settings = template?.settings ?? {} + return { + GUID: template?.GUID, + templateName: template?.templateName ?? '', + description: template?.description ?? '', + roleScope: findOption( + roleScopeOptions, + template?.roleScope ?? 'PrivilegedRoles' + ), + roles: (template?.roles ?? []).map((role) => ({ + label: role.label, + value: role.value, + })), + settings: { + activationMaxDuration: findOption( + activationDurationOptions, + settings.activationMaxDuration ?? 'PT8H' + ), + activationRequires: findOption( + activationRequiresOptions, + settings.activationRequires ?? 'MFA' + ), + authenticationContextClaimValue: + settings.authenticationContextClaimValue ?? '', + activationRequiresTicket: settings.activationRequiresTicket === true, + activationRequiresApproval: settings.activationRequiresApproval === true, + approvers: settings.approvers ?? '', + eligibilityMaxDuration: findOption( + eligibilityDurationOptions, + settings.eligibilityMaxDuration ?? 'P365D' + ), + activeAssignmentMaxDuration: findOption( + activeAssignmentDurationOptions, + settings.activeAssignmentMaxDuration ?? 'P180D' + ), + activeAssignmentRequiresMfa: + settings.activeAssignmentRequiresMfa !== false, + notificationRecipients: settings.notificationRecipients ?? '', + notificationLevel: findOption( + notificationLevelOptions, + settings.notificationLevel ?? 'All' + ), + }, + } +} + +const emailListValidator = (value) => { + const entries = (value ?? '') + .split(/[,;]/) + .map((entry) => entry.trim()) + .filter(Boolean) + const invalid = entries.filter( + (entry) => !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(entry) + ) + return ( + invalid.length === 0 || `Not a valid e-mail address: ${invalid.join(', ')}` + ) +} + +export const CippPIMRoleSettingsTemplateForm = ({ formControl }) => { + const activationMaxDuration = useWatch({ + control: formControl.control, + name: 'settings.activationMaxDuration', + }) + const activationHours = (() => { + const match = /^PT(\d+)H$/.exec(activationMaxDuration?.value ?? '') + return match ? Number(match[1]) : 0 + })() + + return ( + + + Template + + + + + + + + + + + Roles + + + + + + + + options?.length ? true : 'Select at least one role', + }} + /> + + + + + + + Activation (when an eligible admin uses the role) + + + + + CIPP enforces a secure floor: activation always requires a + justification and either MFA or an authentication context, activations + end within 24 hours, and eligible or active assignments end within a + year. Templates below the floor are refused, not adjusted. + + + + + + + + + {activationHours > 8 && ( + + + Activations longer than 8 hours are above the recommended maximum. + The template can be saved, and the override is recorded in the + logbook. + + + )} + + + + + + + + + + + + + + + + + + + + + Assignments (what an administrator may hand out) + + + + + + + + + + + + + + + Notifications + + + + + + + + + ); +} diff --git a/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx b/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx index 05da79b33fbe..135a6da53395 100644 --- a/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx +++ b/src/components/CippFormPages/CippSafeLinksPolicyRuleForm.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Grid } from "@mui/system"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { Typography } from "@mui/material"; @@ -6,7 +7,6 @@ import { CippFormUserSelector } from "../CippComponents/CippFormUserSelector"; import { CippFormGroupSelector } from "../CippComponents/CippFormGroupSelector"; import { CippFormDomainSelector } from "../CippComponents/CippFormDomainSelector"; import { CippInfoCard } from "../CippCards/CippInfoCard"; -import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { getCippValidator } from "../../utils/get-cipp-validator"; import { ApiGetCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; @@ -516,7 +516,7 @@ export const SafeLinksForm = ({ formControl, formType = "add" }) => { { name="RecipientDomainIs" label="Domains" multiple={true} - createable={false} + creatable={false} /> @@ -602,7 +602,7 @@ export const SafeLinksForm = ({ formControl, formType = "add" }) => { name="SentToMemberOf" label="Groups" multiple={true} - createable={false} + creatable={false} /> @@ -612,7 +612,7 @@ export const SafeLinksForm = ({ formControl, formType = "add" }) => { label="Recipients" valueField="userPrincipalName" multiple={true} - createable={false} + creatable={false} /> @@ -624,7 +624,7 @@ export const SafeLinksForm = ({ formControl, formType = "add" }) => { name="ExceptIfRecipientDomainIs" label="Domains" multiple={true} - createable={false} + creatable={false} /> @@ -633,7 +633,7 @@ export const SafeLinksForm = ({ formControl, formType = "add" }) => { name="ExceptIfSentToMemberOf" label="Groups" multiple={true} - createable={false} + creatable={false} /> @@ -643,14 +643,14 @@ export const SafeLinksForm = ({ formControl, formType = "add" }) => { label="Recipients" valueField="userPrincipalName" multiple={true} - createable={false} + creatable={false} /> {/* Information Cards */} } + icon={} label="Propagation Time" value="Changes to Safe Links policies and rules may take up to 6 hours to propagate throughout your organization." isFetching={false} diff --git a/src/components/CippFormPages/CippSchedulerForm.jsx b/src/components/CippFormPages/CippSchedulerForm.jsx index a57d39d0fb01..8dce91edf02a 100644 --- a/src/components/CippFormPages/CippSchedulerForm.jsx +++ b/src/components/CippFormPages/CippSchedulerForm.jsx @@ -12,6 +12,7 @@ import { IconButton, Alert, } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' import { Grid, Stack } from '@mui/system' import { useWatch } from 'react-hook-form' import CippFormComponent from '../CippComponents/CippFormComponent' @@ -26,8 +27,6 @@ import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' import { useEffect, useState } from 'react' import CippFormInputArray from '../CippComponents/CippFormInputArray' import { CippApiResults } from '../CippComponents/CippApiResults' -import { CalendarDaysIcon } from '@heroicons/react/24/outline' -import { ExpandMoreOutlined, Delete, Add, Sync } from '@mui/icons-material' const CippSchedulerForm = (props) => { const { @@ -373,7 +372,9 @@ const CippSchedulerForm = (props) => { // Early return if task is not found if (!task) { - console.warn(`Task with RowKey ${taskId || router.query.id} not found`) + // The id comes from the URL; strip line breaks so it cannot forge extra log lines. + const requestedId = String(taskId || router.query.id).replace(/[\r\n]/g, '') + console.warn(`Task with RowKey ${requestedId} not found`) return } @@ -697,6 +698,18 @@ const CippSchedulerForm = (props) => { options={psaStrategyDropdownOptions} /> + {integrationsConfig?.data?.HaloPSA?.Enabled === true && ( + + + + )} @@ -750,7 +763,7 @@ const CippSchedulerForm = (props) => { > - }> + }> { > Trigger Configuration {getTriggerSummary() && ( - + - {getTriggerSummary()} )} @@ -906,7 +921,7 @@ const CippSchedulerForm = (props) => { Delta Query Conditions diff --git a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx index f2ba4fb1b581..b94a839fb7db 100644 --- a/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx +++ b/src/components/CippIntegrations/CippIntegrationTenantMapping.jsx @@ -3,29 +3,31 @@ import { Button, CardActions, CardContent, + IconButton, Stack, Skeleton, SvgIcon, Tooltip, Typography, } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry" import { Grid } from "@mui/system"; import { useState, useMemo } from "react"; import { useForm } from "react-hook-form"; +import { useQueryClient } from "@tanstack/react-query"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useRouter } from "next/router"; import extensions from "../../data/Extensions.json"; import { useEffect } from "react"; import { CippDataTable } from "../CippTable/CippDataTable"; -import { PlusSmallIcon, SparklesIcon, TrashIcon } from "@heroicons/react/24/outline"; import { CippFormTenantSelector } from "../CippComponents/CippFormTenantSelector"; -import { Sync, SyncAlt } from "@mui/icons-material"; import { CippFormComponent } from "../CippComponents/CippFormComponent"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { ApiGetCallWithPagination } from "../../api/ApiCall"; const CippIntegrationSettings = ({ children }) => { const router = useRouter(); + const queryClient = useQueryClient(); const [tableData, setTableData] = useState([]); const mappings = ApiGetCall({ @@ -59,6 +61,11 @@ const CippIntegrationSettings = ({ children }) => { relatedQueryKeys: [`IntegrationTenantMapping-${router.query.id}`], }); + const [syncTenantQuery, setSyncTenantQuery] = useState({ url: "", waiting: false, queryKey: "" }); + const syncTenantResults = ApiGetCall({ + ...syncTenantQuery, + }); + const handleSubmit = () => { postCall.mutate({ url: `/api/ExecExtensionMapping?AddMapping=${router.query.id}`, @@ -156,10 +163,42 @@ const CippIntegrationSettings = ({ children }) => { } }; + // Sync a single mapped tenant on demand. The backend already supports this via the TenantID + // query param; we also pass the domain so the queued run is tagged to the tenant in the logbook. + const handleSyncTenant = (row) => { + const target = Array.isArray(row) ? row[0] : row; + if (!target?.TenantId) return; + // Re-clicking the same tenant reuses the query key, so trigger a refetch instead. + if (syncTenantQuery.waiting && syncTenantQuery.data?.TenantID === target.TenantId) { + syncTenantResults.refetch(); + return; + } + setSyncTenantQuery({ + url: "/api/ExecExtensionSync", + data: { + Extension: router.query.id, + TenantID: target.TenantId, + TenantFilter: target.TenantDomain, + }, + waiting: true, + queryKey: `ExecExtensionSync-${router.query.id}-${target.TenantId}`, + }); + }; + const actions = [ + { + label: "Sync Now", + icon: ( + + + + ), + confirmText: "Queue a NinjaOne sync for [Tenant]?", + customFunction: handleSyncTenant, + }, { label: "Delete Mapping", - icon: , + icon: , confirmText: "Are you sure you want to delete this mapping?", customFunction: handleRemoveItem, }, @@ -197,56 +236,81 @@ const CippIntegrationSettings = ({ children }) => { }} > - - - + + + + + + + queryClient.invalidateQueries({ queryKey: ["ListTenants-FormnotAllTenants"] }) + } + > + + + + + + - + - { - return { - label: company.name, - value: company.value, - }; - })} - creatable={false} - multiple={false} - isFetching={mappings.isFetching} - sortOptions={true} - /> + + + { + return { + label: company.name, + value: company.value, + }; + })} + creatable={false} + multiple={false} + isFetching={mappings.isFetching} + sortOptions={true} + /> + + + mappings.refetch()}> + + + + + + @@ -259,7 +323,7 @@ const CippIntegrationSettings = ({ children }) => { variant="contained" > - + @@ -279,6 +343,7 @@ const CippIntegrationSettings = ({ children }) => { refreshFunction={() => mappings.refetch()} /> + diff --git a/src/components/CippPdf/CippBrandingReportPreview.jsx b/src/components/CippPdf/CippBrandingReportPreview.jsx index c22c489426ea..70f05e118e5b 100644 --- a/src/components/CippPdf/CippBrandingReportPreview.jsx +++ b/src/components/CippPdf/CippBrandingReportPreview.jsx @@ -5,6 +5,7 @@ import { ShadowAIReportDocument } from '../ShadowAIReportButton' import { BECRemediationReportDocument } from '../BECRemediationReportButton' import { SharingReportDocument } from './SharingReportButton' import { PermissionsReportDocument } from './PermissionsReportButton' +import { MailFlowReportDocument } from './MailFlowReportButton' import { ReportBuilderDocument } from '../ReportBuilder/ReportBuilderPDF' import { SAMPLE_DATA_BY_REPORT, SAMPLE_TENANT_NAME } from './previewSampleData' import { useReportVariables } from './useReportVariables' @@ -69,6 +70,8 @@ export const buildPreviewDocument = (reportType, brandingSettings, variables) => return case 'permissions': return + case 'mailFlow': + return default: return ( diff --git a/src/components/CippPdf/CippPdfPreview.jsx b/src/components/CippPdf/CippPdfPreview.jsx index 85615bdcc874..af46101ee3f1 100644 --- a/src/components/CippPdf/CippPdfPreview.jsx +++ b/src/components/CippPdf/CippPdfPreview.jsx @@ -1,5 +1,5 @@ import { Box, Button, CircularProgress, Stack, Typography } from '@mui/material' -import { Download, OpenInNew, PictureAsPdf } from '@mui/icons-material' +import { CippIcons } from '../../utils/icon-registry' import { PDFViewer, usePDF } from '@react-pdf/renderer' import { useIsMobileLayout } from '../../hooks/use-breakpoint' @@ -23,35 +23,56 @@ const MobileHandoff = ({ document, fileName, title, showDownload }) => { if (instance.loading) { return ( - + - + Building report… - ) + ); } if (instance.error || !instance.url) { return ( - + Report could not be generated - + {instance.error ? String(instance.error) : 'No document was produced.'} - ) + ); } const size = formatSize(instance.blob?.size) return ( + sx={{ + alignItems: "center", + justifyContent: "center", + height: '100%', + p: 3, + textAlign: 'center' + }}> { bgcolor: 'action.hover', }} > - + @@ -70,7 +91,9 @@ const MobileHandoff = ({ document, fileName, title, showDownload }) => { {title ?? 'Report'} {size && ( - + PDF · {size} )} @@ -83,7 +106,7 @@ const MobileHandoff = ({ document, fileName, title, showDownload }) => { target="_blank" rel="noopener noreferrer" variant="contained" - startIcon={} + startIcon={} sx={{ minHeight: 44 }} > Open report @@ -96,7 +119,7 @@ const MobileHandoff = ({ document, fileName, title, showDownload }) => { href={instance.url} download={fileName ?? 'report.pdf'} variant="outlined" - startIcon={} + startIcon={} sx={{ minHeight: 44 }} > Download @@ -104,7 +127,7 @@ const MobileHandoff = ({ document, fileName, title, showDownload }) => { )} - ) + ); } /** diff --git a/src/components/CippPdf/MailFlowReportButton.jsx b/src/components/CippPdf/MailFlowReportButton.jsx new file mode 100644 index 000000000000..83576f9177b4 --- /dev/null +++ b/src/components/CippPdf/MailFlowReportButton.jsx @@ -0,0 +1,454 @@ +import { useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Tooltip, + Typography, +} from '@mui/material' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdfPreview' +import { + AlertBox, + Bold, + BulletList, + ClearBox, + ContentPage, + CoverMeta, + DataTable, + DonutChart, + InfoBox, + Paragraph, + REPORT_COLOURS, + ReportDocument, + Section, + StatRow, + TrendChart, + severityColour, +} from './index' +import { useReportVariables } from './useReportVariables' +import { useBrandingSettings } from './useBrandingSettings' + +const nz = (value) => Number(value ?? 0) +const num = (value) => nz(value).toLocaleString() +const pct = (part, whole) => (whole > 0 ? Math.round((part / whole) * 1000) / 10 : 0) + +// Get-MailFlowStatusReport event types, in the order they read as a funnel: delivered mail first, +// then the things that stopped mail, worst last. +const DISPOSITIONS = [ + { key: 'GoodMail', label: 'Good mail' }, + { key: 'TransportRules', label: 'Transport rules' }, + { key: 'SpamDetections', label: 'Spam' }, + { key: 'EdgeBlockSpam', label: 'Edge blocked spam' }, + { key: 'EmailPhish', label: 'Phish' }, + { key: 'EmailMalware', label: 'Malware' }, +] + +/** + * Grades mail hygiene from the share of the window's mail that each threat class accounts for. + * + * Shares rather than counts, because a raw count of blocked phish means nothing without the volume + * it was drawn from. Malware and phish are weighted far harder than spam: spam is nuisance traffic + * every tenant carries, while a targeted-payload rate above roughly one percent of all mail means + * the organisation is being actively worked on rather than incidentally scraped. + */ +const assessHygiene = (totals, totalMail) => { + if (totalMail <= 0) return { level: 'Good', severity: 'low' } + const targeted = pct(nz(totals.EmailMalware) + nz(totals.EmailPhish), totalMail) + const spam = pct(nz(totals.SpamDetections) + nz(totals.EdgeBlockSpam), totalMail) + + if (targeted > 1 || spam > 25) return { level: 'Attention Needed', severity: 'high' } + if (targeted > 0.25 || spam > 10) return { level: 'Fair', severity: 'medium' } + return { level: 'Good', severity: 'low' } +} + +// Exported so the branding preview can render this report against sample data, and so tests can +// render it to a real PDF. +export const MailFlowReportDocument = ({ + mailFlowData, + brandingSettings, + tenantName, + generatedOn, + variables, +}) => { + const days = nz(mailFlowData?.days) || 14 + const totals = mailFlowData?.totals ?? {} + const directionTotals = mailFlowData?.directionTotals ?? {} + const daily = mailFlowData?.daily ?? [] + const topSenders = mailFlowData?.topSenders ?? [] + const topSpamRecipients = mailFlowData?.topSpamRecipients ?? [] + + const totalMail = DISPOSITIONS.reduce((sum, item) => sum + nz(totals[item.key]), 0) + const goodMailPct = pct(nz(totals.GoodMail), totalMail) + const phish = nz(totals.EmailPhish) + const malware = nz(totals.EmailMalware) + const threats = phish + malware + const transportRules = nz(totals.TransportRules) + + const hygiene = assessHygiene(totals, totalMail) + const hygieneColour = severityColour(hygiene.severity) + + const dayLabel = (value) => + value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '' + + const volumeSeries = daily.map((row) => ({ + label: dayLabel(row.date), + value: DISPOSITIONS.reduce((sum, item) => sum + nz(row[item.key]), 0), + })) + + const directionSeries = [ + { label: 'Inbound', value: nz(directionTotals.Inbound) }, + { label: 'Outbound', value: nz(directionTotals.Outbound) }, + { label: 'Intra-org', value: nz(directionTotals.IntraOrg) }, + ] + + // Numbered after filtering, so a report that drops a conditional action still counts from one. + const priorityActions = [ + threats > 0 && { + label: 'Review anti-phishing and anti-malware policy strength.', + text: `${num(threats)} messages were blocked as phishing or malware in this window. Confirm the tenant is on the current preset security policies, that impersonation protection lists the people who would actually be impersonated, and that Safe Links and Safe Attachments cover every mailbox rather than a pilot group.`, + }, + topSpamRecipients.length > 0 && { + label: 'Give the most-targeted users stronger protection.', + text: 'The recipients listed in this report absorb a disproportionate share of unwanted mail. Priority accounts, tighter quarantine policy and a short conversation about what they are receiving cost little and are aimed exactly where the traffic is going.', + }, + { + label: 'Verify SPF, DKIM and DMARC are published and enforcing.', + text: 'These records decide whether mail claiming to be from the domain is accepted elsewhere. A DMARC policy left at p=none reports abuse without stopping it, which means the organisation can be impersonated to its own customers regardless of how well inbound filtering performs.', + }, + transportRules > 0 && { + label: 'Audit the transport rules acting on mail.', + text: `Transport rules handled ${num(transportRules)} messages here. Rules accumulate, outlive the reason they were written, and silently override filtering decisions — confirm each one is still wanted and that none bypasses protection for a sender that no longer needs the exception.`, + }, + ] + .filter(Boolean) + .map((item, index) => ({ ...item, marker: `${index + 1}.` })) + + const dispositionRows = DISPOSITIONS.map((item) => ({ + disposition: item.label, + messages: num(totals[item.key]), + share: `${pct(nz(totals[item.key]), totalMail)}%`, + })) + + return ( + + } + > + {/* EXECUTIVE SUMMARY */} + +
    + + Every message entering or leaving the organisation is given a disposition — delivered, + held by a transport rule, filtered as spam, or blocked as phishing or malware. Those + dispositions are the clearest single measure of what the mail environment is being asked + to handle, because they count what the filters actually did rather than what they are + configured to do. This report covers the last {days} days of mail flow at{' '} + {tenantName}. + + + 0 ? REPORT_COLOURS.warning : undefined, + }, + { + value: num(malware), + label: 'Malware Blocked', + colour: malware > 0 ? REPORT_COLOURS.danger : undefined, + }, + ]} + /> + + + {hygiene.level === 'Attention Needed' && + 'Threat traffic is a material share of total mail. At this rate the organisation is being targeted rather than incidentally caught by bulk campaigns, and the filters are absorbing volume that protection policy and user awareness should be reducing at source. Treat the recommendations as current work.'} + {hygiene.level === 'Fair' && + 'Threats are being caught at a level that is normal for an organisation of this profile, but not negligible. The filtering is working; the value now is in checking which users absorb most of it and whether their protection matches their exposure.'} + {hygiene.level === 'Good' && + 'Threat traffic is a small fraction of total mail and is being stopped before delivery. Nothing here needs action beyond keeping the review cadence, since a change in this profile is usually the first visible sign of a campaign starting.'} + +
    + +
    + + Figures come from Microsoft's mail flow status report for the tenant, aggregated as daily + counts per disposition and direction. It is a count of messages, not a record of them: + individual senders, subjects and recipients are not part of this data set, and a message + appears once under the disposition that was applied to it. + + + A blocked message is a filter working, not an incident. Nothing here indicates that a + threat reached a user or that an account was compromised — that requires message trace + and sign-in data, which are reviewed separately. Equally, a clean result does not prove + nothing got through; it proves nothing was recognised. + +
    +
    + + {/* VOLUME & DISPOSITIONS */} + +
    + + Total messages handled per day across every disposition. Steady volume with occasional + peaks is normal; a sustained step change usually reflects a business event — a campaign, + an onboarding, a new integration — and is worth being able to explain. + + +
    + +
    + + The share each disposition accounts for matters more than the counts. Good mail should + dominate; anything else growing as a proportion is the signal. + + +
    + +
    + + Inbound, outbound and internal traffic in proportion. An unusual outbound share is the + one to watch: mail leaving in volume that the business did not generate is how a + compromised mailbox or an unsecured relay first shows up in these figures. + + +
    +
    + + {/* SENDERS & SPAM TARGETS */} + +
    + + The heaviest senders are normally the ones you would expect — shared mailboxes, + ticketing systems, scan-to-email devices, marketing platforms. What is worth a second + look is a name that does not belong on that list. A user account sending at machine + volume is either an unmanaged automation nobody documented, or a mailbox someone else is + using. + + {topSenders.length > 0 ? ( + ({ + name: row.Name ?? row.name ?? 'Unknown', + count: num(row.Count ?? row.count), + }))} + limit={10} + /> + ) : ( + + Microsoft returned no top-sender breakdown for this window. + + )} +
    + +
    + + Unwanted mail does not spread evenly. A handful of addresses — usually the published + ones, and the people whose names appear on the website — absorb most of it, and those + same addresses are the ones a targeted attempt will use. Concentration here identifies + exactly who benefits most from stricter policy and from being asked to be careful. + + {topSpamRecipients.length > 0 ? ( + ({ + name: row.Name ?? row.name ?? 'Unknown', + count: num(row.Count ?? row.count), + }))} + limit={10} + /> + ) : ( + + No recipient stands out as absorbing spam over this window. + + )} +
    +
    + + {/* RECOMMENDATIONS */} + +
    + + Ordered by what this window's data actually shows, rather than by a generic checklist. + + +
    + +
    + +
    +
    +
    + ) +} + +export const MailFlowReportButton = ({ mailFlowData, tenantName, disabled = false }) => { + const [dialogOpen, setDialogOpen] = useState(false) + const [generatedOn, setGeneratedOn] = useState('') + const brandingSettings = useBrandingSettings() + const variables = useReportVariables() + const hasData = Object.keys(mailFlowData?.totals ?? {}).length > 0 + + const handleOpen = () => { + setGeneratedOn( + new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + ) + setDialogOpen(true) + } + + const documentNode = ( + + ) + + return ( + <> + + + + + + + setDialogOpen(false)} + maxWidth="lg" + fullWidth + slotProps={{ + paper: { sx: { height: '90vh' } } + }} + > + + + + Mail Flow Report Preview + + setDialogOpen(false)} size="small"> + + + + + + {dialogOpen && ( + + {documentNode} + + )} + + + + + {({ loading }) => ( + + )} + + + + + ); +} + +export default MailFlowReportButton diff --git a/src/components/CippPdf/PermissionsReportButton.jsx b/src/components/CippPdf/PermissionsReportButton.jsx index 9256f8d7b78f..26957d02cc76 100644 --- a/src/components/CippPdf/PermissionsReportButton.jsx +++ b/src/components/CippPdf/PermissionsReportButton.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Box, Button, @@ -11,7 +12,6 @@ import { Tooltip, Typography, } from '@mui/material' -import { Close, Download, PictureAsPdf } from '@mui/icons-material' import { PDFDownloadLink } from '@react-pdf/renderer' import { CippPdfPreview } from './CippPdfPreview' import { @@ -449,7 +449,7 @@ export const PermissionsReportButton = ({ permissionsData, tenantName }) => { + } + > + If Azure keeps rejecting the binding, add the domain on the App Service's + Custom domains page instead, then reopen it here to provision the certificate. + + ) : null}
    )} @@ -345,7 +395,7 @@ const DomainWizard = ({ open, onClose, siteInfo, initialDomain }) => { {activeStep === 2 && ( {certDone ? ( - }> + }> {hostname} is fully configured and secured with a managed certificate. @@ -354,17 +404,29 @@ const DomainWizard = ({ open, onClose, siteInfo, initialDomain }) => { App Service Managed Certificates don't support wildcard domains. Upload your own certificate and binding from the Azure Portal to secure {hostname}. + ) : certJobActive ? ( + + A certificate for {hostname} is being issued in the background + (attempt {initialDomain.CertJobAttempt} of {initialDomain.CertJobMaxAttempts} + {initialDomain.CertJobNextRun + ? `, next try at ${new Date(initialDomain.CertJobNextRun).toLocaleString()}` + : ""} + ). Close this dialog; the table updates as it progresses. + ) : ( <> Provision a free App Service Managed Certificate for {hostname}{" "} - and enable the SNI SSL binding. This can take a minute or two. + and enable the SNI SSL binding. Issuance usually takes a minute or two; if it takes + longer, CIPP keeps retrying in the background every 15 minutes. - If the domain's alias is proxied through a CDN (e.g. Cloudflare orange-cloud), - temporarily set it to DNS-only while the certificate is issued, then re-enable the - proxy afterwards. Certificate issuance validates the domain directly. + The domain must point directly at the App Service. A proxy or CDN in front of it + (e.g. a Cloudflare proxied record) blocks certificate issuance and renewal. + {managing && initialDomain?.CertJobResult ? ( + Last attempt: {initialDomain.CertJobResult} + ) : null} )} @@ -392,7 +454,7 @@ const DomainWizard = ({ open, onClose, siteInfo, initialDomain }) => { variant="outlined" onClick={runDnsCheck} disabled={!hostnameValid || dnsCheck.isPending} - startIcon={dnsCheck.isPending ? : } + startIcon={dnsCheck.isPending ? : } > {dnsCheck.isPending ? "Checking..." : "Check DNS"} @@ -413,18 +475,18 @@ const DomainWizard = ({ open, onClose, siteInfo, initialDomain }) => { )} - {activeStep === 2 && !certDone && !isWildcard && ( + {activeStep === 2 && !certDone && !certPending && !certJobActive && !isWildcard && ( )} - {activeStep === 2 && (certDone || isWildcard) && ( + {activeStep === 2 && (certDone || certPending || certJobActive || isWildcard) && ( @@ -438,6 +500,9 @@ const DomainWizard = ({ open, onClose, siteInfo, initialDomain }) => { export const CippAppServiceDomains = () => { const [wizardOpen, setWizardOpen] = useState(false); const [managingDomain, setManagingDomain] = useState(null); + // Hosted instances sit on a shared App Service plan the instance identity cannot change, so + // domains are managed in the management portal and this page is read-only. + const { isHosted } = usePermissions(); const domainsQuery = ApiGetCall({ url: "/api/ExecAppServiceDomains", @@ -450,7 +515,7 @@ export const CippAppServiceDomains = () => { const list = siteInfo?.Domains ?? []; return list.map((d) => ({ ...d, - Status: d.IsDefault ? "Default (Azure-managed)" : sslStateLabel(d.SslState), + Status: domainStatus(d), })); }, [siteInfo]); @@ -469,7 +534,7 @@ export const CippAppServiceDomains = () => { label: "Manage / Fix", icon: ( - + ), noConfirm: true, @@ -480,7 +545,7 @@ export const CippAppServiceDomains = () => { label: "Remove domain", icon: ( - + ), color: "error.main", @@ -498,7 +563,9 @@ export const CippAppServiceDomains = () => { children: (row) => ( - + Hostname @@ -506,24 +573,40 @@ export const CippAppServiceDomains = () => { - + {row.Secured ? ( - + ) : ( - + )} {sslStateLabel(row.SslState)} {row.HostNameType && ( - + Binding type: {row.HostNameType} )} + {row.CertJobNextRun && ( + + Next certificate attempt: {new Date(row.CertJobNextRun).toLocaleString()} + + )} + {row.CertJobResult && !row.Secured && ( + + Last certificate attempt: {row.CertJobResult} + + )} {row.CertThumbprint && ( <> - + Certificate thumbprint @@ -531,7 +614,9 @@ export const CippAppServiceDomains = () => { {row.CertExpiration && ( - + Expires: {new Date(row.CertExpiration).toLocaleString()} )} @@ -544,12 +629,34 @@ export const CippAppServiceDomains = () => { return ( - - Map custom domains to the App Service that hosts this CIPP instance. Each domain needs a - DNS alias record, a hostname binding, and (optionally) a free managed TLS certificate — - the wizard walks through all three and can be reopened at any time to finish or fix a - domain. The default *.azurewebsites.net hostname always remains available. - + {isHosted ? ( + + Open management portal + + } + > + Custom domains for hosted instances are managed in the management portal. This list + is read-only. + + ) : ( + + Map custom domains to the App Service that hosts this CIPP instance. Each domain needs a + DNS alias record, a hostname binding, and (optionally) a free managed TLS certificate — + the wizard walks through all three and can be reopened at any time to finish or fix a + domain. The default *.azurewebsites.net hostname always remains available. + Point the domain directly at the App Service — a proxy or CDN in front of CIPP blocks + certificate issuance and renewal. + + )} @@ -571,7 +678,9 @@ export const CippAppServiceDomains = () => { - + Use the default hostname as the CNAME target for subdomains, and the inbound IP as the A record for apex domains. CIPP no longer uses domain-verification TXT records — remove any leftover asuid.<domain> record. @@ -589,21 +698,23 @@ export const CippAppServiceDomains = () => { isFetching={domainsQuery.isFetching} refreshFunction={domainsQuery.refetch} simpleColumns={["Hostname", "Status"]} - actions={actions} + actions={isHosted ? [] : actions} offCanvas={offCanvas} cardButton={ - + isHosted ? null : ( + + ) } /> diff --git a/src/components/CippSettings/CippBackendCard.jsx b/src/components/CippSettings/CippBackendCard.jsx index 6c9465ae5a90..16c740811473 100644 --- a/src/components/CippSettings/CippBackendCard.jsx +++ b/src/components/CippSettings/CippBackendCard.jsx @@ -1,12 +1,15 @@ -import { OpenInNew } from "@mui/icons-material"; import CippButtonCard from "../CippCards/CippButtonCard"; +import { CippIcons } from "../../utils/icon-registry"; import { Button, Stack, SvgIcon, Typography } from "@mui/material"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { useState } from "react"; +import { usePermissions } from "../../hooks/use-permissions"; import { getCippTranslation } from "../../utils/get-cipp-translation"; -export const CippBackendCard = ({ backendComponents, item, hosted }) => { +export const CippBackendCard = ({ backendComponents, item }) => { const [open, setOpen] = useState(false); + // Hosted instances cannot run the Cloud Shell command reference against their own infrastructure + const { isHosted } = usePermissions(); const BackendButton = () => { return ( @@ -21,7 +24,7 @@ export const CippBackendCard = ({ backendComponents, item, hosted }) => { {...item?.linkProps} > - + Launch @@ -30,7 +33,7 @@ export const CippBackendCard = ({ backendComponents, item, hosted }) => { variant="contained" size="small" onClick={() => setOpen(true)} - disabled={backendComponents.isFetching || hosted} + disabled={backendComponents.isFetching || isHosted} startIcon={ item.offcanvasIcon ? {item.offcanvasIcon} : "" } diff --git a/src/components/CippSettings/CippBackupRetentionSettings.jsx b/src/components/CippSettings/CippBackupRetentionSettings.jsx index 5b9310a85728..78b151e65d7c 100644 --- a/src/components/CippSettings/CippBackupRetentionSettings.jsx +++ b/src/components/CippSettings/CippBackupRetentionSettings.jsx @@ -66,11 +66,13 @@ const CippBackupRetentionSettings = () => { value={retentionDays} onChange={handleInputChange} disabled={retentionChange.isPending || retentionSetting.isLoading} - inputProps={{ min: 7 }} error={!!error} helperText={error} sx={{ width: "120px" }} label="Days" + slotProps={{ + htmlInput: { min: 7 } + }} /> diff --git a/src/components/CippSettings/CippBrandingCoverPreview.jsx b/src/components/CippSettings/CippBrandingCoverPreview.jsx index 3dc4b86c4a8f..4fbd0ad2a9aa 100644 --- a/src/components/CippSettings/CippBrandingCoverPreview.jsx +++ b/src/components/CippSettings/CippBrandingCoverPreview.jsx @@ -4,6 +4,7 @@ import { createReportStyles } from "../CippPdf/reportPdfStyles"; import { applyFooterText, applyWatermarkText, createReportTheme } from "../CippPdf/reportTheme"; import { SAMPLE_BEC, + SAMPLE_MAIL_FLOW, SAMPLE_PERMISSIONS, SAMPLE_SHARING, SAMPLE_TENANT_NAME, @@ -21,6 +22,8 @@ const SAMPLE_ANALYSIS_DATE = new Date(SAMPLE_BEC.becData.ExtractedAt).toLocaleSt minute: "2-digit", }); +const SAMPLE_MAIL_FLOW_TOTAL = Object.values(SAMPLE_MAIL_FLOW.totals).reduce((a, b) => a + b, 0); + /** * Every report CIPP can produce, in the order they are offered everywhere: the built-in reports * first, and the report builder — which renders whatever an operator assembles — last. @@ -103,6 +106,21 @@ export const REPORT_COVER_PRESETS = [ metaSecondary: `${SAMPLE_PERMISSIONS.summary.sitesScanned} sites · ${SAMPLE_PERMISSIONS.summary.librariesScanned} libraries · ${SAMPLE_PERMISSIONS.summary.totalAssignments} permission assignments`, footer: "Confidential — For Internal Use Only", }, + { + id: "mailFlow", + label: "Mail Flow Report", + reportName: "Mail Flow Report", + coverLabel: "Email Traffic Review", + title: "Mail Flow", + accent: "Report", + subtitle: + "Where email came from, how much of it was delivered, and what was stopped before it reached a mailbox.", + metaPrimary: SAMPLE_TENANT_NAME, + metaSecondary: `${SAMPLE_MAIL_FLOW_TOTAL.toLocaleString()} messages · ${SAMPLE_MAIL_FLOW.days} days · ${( + SAMPLE_MAIL_FLOW.totals.EmailPhish + SAMPLE_MAIL_FLOW.totals.EmailMalware + ).toLocaleString()} threats caught`, + footer: "Confidential — For Internal Use Only", + }, { // Last: this one has no fixed content of its own — it renders whatever an operator assembles in // the report builder, so it belongs after the reports that are the same every time. diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx index 44ca040a3b06..8d4ab1d55d68 100644 --- a/src/components/CippSettings/CippBrandingSettings.jsx +++ b/src/components/CippSettings/CippBrandingSettings.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry" import { Button, Typography, @@ -14,7 +15,6 @@ import { MenuItem, TextField, } from "@mui/material"; -import { Add, Delete, Palette } from "@mui/icons-material"; import { Grid } from "@mui/system"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { useSettings } from "../../hooks/use-settings"; @@ -156,7 +156,7 @@ const GalleryTile = ({ }} > {add ? ( - + ) : empty ? ( - + )} @@ -822,7 +822,6 @@ const CippBrandingSettings = () => { const brandingData = buildLocalBranding(); - brandingApi.mutate({ url: "/api/ExecBrandingSettings", data: { @@ -884,7 +883,6 @@ const CippBrandingSettings = () => { previewReportType: formControl.getValues("previewReportType") || reportTypeOptions[0], }); - brandingApi.mutate({ url: "/api/ExecBrandingSettings", data: { @@ -916,7 +914,9 @@ const CippBrandingSettings = () => { Branding - + Customize your organization's branding for reports and documents. The default applies to every report; presets can be assigned to individual report templates instead. @@ -926,7 +926,14 @@ const CippBrandingSettings = () => { Editing - + { ))} diff --git a/src/components/CippSettings/CippCacheSettings.jsx b/src/components/CippSettings/CippCacheSettings.jsx index 3b9ebf55ec53..0a238e16fcde 100644 --- a/src/components/CippSettings/CippCacheSettings.jsx +++ b/src/components/CippSettings/CippCacheSettings.jsx @@ -1,9 +1,9 @@ import { Button, SvgIcon, Typography } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import CippButtonCard from "../CippCards/CippButtonCard"; import { ApiPostCall } from "../../api/ApiCall"; import { CippApiDialog } from "../CippComponents/CippApiDialog"; import { useDialog } from "../../hooks/use-dialog"; -import { TrashIcon } from "@heroicons/react/24/outline"; const CippCacheSettings = () => { const createDialog = useDialog(); @@ -35,7 +35,7 @@ const CippCacheSettings = () => { disabled={resolverChange.isPending} > - + Clear Cache diff --git a/src/components/CippSettings/CippContainerManagement.jsx b/src/components/CippSettings/CippContainerManagement.jsx index 96a1c96a0a76..7f91a5ec49d1 100644 --- a/src/components/CippSettings/CippContainerManagement.jsx +++ b/src/components/CippSettings/CippContainerManagement.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo } from 'react' +import { CippIcons } from '../../utils/icon-registry' import { Alert, Box, @@ -11,19 +12,6 @@ import { Stack, Typography, } from '@mui/material' -import { - CheckCircle, - NewReleases, - Schedule, - Layers, - Sell, - HelpOutline, - Refresh, - CloudSync, - RestartAlt, - Save, - SwapHoriz, -} from '@mui/icons-material' import { Grid } from '@mui/system' import { useForm, useWatch } from 'react-hook-form' import CippFormComponent from '../CippComponents/CippFormComponent' @@ -148,7 +136,7 @@ export const CippContainerManagement = () => { if (channelLabels[value]) return channelLabels[value].label const pinned = /-([0-9a-f]{7})$/.exec(value ?? '') if (pinned) - return `${value.replace(/-[0-9a-f]{7}$/, '')} — pinned ${pinned[1]}` + return `${value.replace(/-[0-9a-f]{7}$/, '')} — pinned ${pinned[1]}`; return value } @@ -280,21 +268,21 @@ export const CippContainerManagement = () => { ]) const updateState = !updateSettings?.LastCheck - ? { label: 'Never checked', color: 'primary', icon: } + ? { label: 'Never checked', color: 'primary', icon: } : updateSettings.UpdateAvailable - ? { label: 'Update available', color: 'info', icon: } - : { label: 'Up to date', color: 'success', icon: } + ? { label: 'Update available', color: 'info', icon: } + : { label: 'Up to date', color: 'success', icon: } return [ { - icon: , + icon: , name: 'Release Channel', data: channelInfo.label, color: channelInfo.color === 'default' ? 'primary' : channelInfo.color, toolTip: `Running image tag: ${data?.ImageTag ?? 'unknown'}`, }, { - icon: , + icon: , name: 'App Version', data: commit ? `${version} @${commit}` : version, color: 'primary', @@ -312,7 +300,7 @@ export const CippContainerManagement = () => { : undefined, }, { - icon: , + icon: , name: 'Last Checked', data: relativeTime(updateSettings?.LastCheck) ?? 'Never', color: 'primary', @@ -368,15 +356,16 @@ export const CippContainerManagement = () => { + sx={{ + justifyContent: "flex-end", + flexWrap: "wrap" + }}> {/* Re-reads the Status endpoint only. Distinct from "Check for Updates", which hits the container registry and can trip auto-restart when an update is found. */} diff --git a/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx b/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx index c3e2dd411a90..9f8bc2f239ee 100644 --- a/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx +++ b/src/components/CippSettings/CippGDAP/CippFlowDiagram.jsx @@ -1,6 +1,6 @@ import React from "react"; +import { CippIcons } from "../../../utils/icon-registry"; import { Box, Typography, Chip, Stack, Paper } from "@mui/material"; -import { ArrowForward, ArrowDownward } from "@mui/icons-material"; /** * Generic Flow Diagram Component @@ -21,7 +21,7 @@ export const CippFlowDiagram = ({ if (!nodes || nodes.length === 0) return null; const isHorizontal = direction === "horizontal"; - const ArrowIcon = isHorizontal ? ArrowForward : ArrowDownward; + const ArrowIcon = isHorizontal ? CippIcons.ArrowForward : CippIcons.ArrowDownward; return ( {node.subLabel && ( - + {node.subLabel} )} {node.chips && node.chips.length > 0 && ( - + {node.chips.map((chip, chipIndex) => ( ))} @@ -86,7 +96,9 @@ export const CippFlowDiagram = ({ {node.details && ( {typeof node.details === "string" ? ( - + {node.details} ) : ( diff --git a/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx b/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx index 7e24a05be48a..e7a0e689cca9 100644 --- a/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx +++ b/src/components/CippSettings/CippGDAP/CippGDAPTrace.jsx @@ -1,5 +1,5 @@ import React, { forwardRef, useImperativeHandle, useRef, useMemo } from "react"; -import { Security } from "@mui/icons-material"; +import { CippIcons } from "../../../utils/icon-registry" import { useDialog } from "../../../hooks/use-dialog"; import { CippGDAPTraceDialog } from "./CippGDAPTraceDialog"; @@ -42,7 +42,7 @@ export function useCippGDAPTrace() { const traceGdapAction = useMemo( () => ({ label: "Trace GDAP", - icon: , + icon: , noConfirm: true, customFunction: (row) => ref.current?.open(row), // Direct tenants have no GDAP relationship to trace. diff --git a/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx b/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx index 2d893fbe1bbe..5fb15ed31764 100644 --- a/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx +++ b/src/components/CippSettings/CippGDAP/CippGDAPTraceResults.jsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { CippIcons } from "../../../utils/icon-registry" import { Box, Card, @@ -16,16 +17,6 @@ import { Tooltip, IconButton, } from "@mui/material"; -import { - ExpandMore, - CheckCircle, - Cancel, - Warning, - Security, - Group, - AccountTree, - InfoOutlined, -} from "@mui/icons-material"; import { CippCodeBlock } from "../../CippComponents/CippCodeBlock"; import { CippPathVisualization } from "./CippPathVisualization"; import { getCippRoleTranslation } from "../../../utils/get-cipp-role-translation"; @@ -89,13 +80,13 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { const getRoleStatusChip = (role) => { if (role.isUserHasAccess) { - return } label="Has Access" color="success" size="small" />; + return } label="Has Access" color="success" size="small" />; } else if (role.isAssigned) { - return } label="Assigned but No Access" color="warning" size="small" />; + return } label="Assigned but No Access" color="warning" size="small" />; } else if (role.roleExistsInRelationship) { - return } label="In Relationship but Not Assigned" color="info" size="small" />; + return } label="In Relationship but Not Assigned" color="info" size="small" />; } else { - return } label="Not In Any Relationship" color="default" size="small" />; + return } label="Not In Any Relationship" color="default" size="small" />; } }; @@ -115,7 +106,9 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { {sortedPath.map((step, index) => ( - + {hasMultipleGroups && step.sequence !== undefined && ( { )} {step.membershipType === "direct" && ( } + icon={} label="Direct" color="success" size="small" @@ -135,7 +128,7 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { )} {step.membershipType === "nested" && ( } + icon={} label="Nested" color="info" size="small" @@ -144,7 +137,7 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { )} {step.membershipType === "not_member" && ( } + icon={} label="Not Member" color="error" size="small" @@ -157,7 +150,9 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { {index < sortedPath.length - 1 && ( - + @@ -178,41 +173,53 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { - - + + Tenant {tenantName} - - + + User {userDisplayName || userUPN} - - + + Total Relationships {summary?.totalRelationships || 0} - - + + Roles with Access {summary?.rolesWithAccess || 0} / {summary?.totalRoles || 15} - - - + + + Roles Assigned but No Access { arrow > - + @@ -229,9 +236,13 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { {summary?.rolesAssignedButNoAccess || 0} - - - + + + Roles In Relationship but Not Assigned { arrow > - + @@ -248,9 +259,13 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { {summary?.rolesInRelationshipButNotAssigned || 0} - - - + + + Roles Not In Any Relationship { arrow > - + @@ -275,7 +290,7 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { - + GDAP Roles Access @@ -288,7 +303,7 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { expanded={expandedRoles[role.roleId] || false} onChange={() => handleRoleExpand(role.roleId)} > - }> + }> {getRoleStatusChip(role)} @@ -296,7 +311,9 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { {role.roleName} {role.roleDescription && ( - + {role.roleDescription} )} @@ -307,7 +324,12 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { {role.isUserHasAccess && role.accessPaths && role.accessPaths.length > 0 ? ( <> - + Access Paths ({role.accessPaths.length}): {role.accessPaths.map((path, pathIndex) => ( @@ -375,7 +397,9 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { {role.relationshipsWithRole && role.relationshipsWithRole.length > 0 && ( - + All relationships with this role: {role.relationshipsWithRole.length} @@ -396,7 +420,7 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { - + GDAP Relationships @@ -407,7 +431,7 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { expanded={expandedRelationships[relationship.relationshipId] || false} onChange={() => handleRelationshipExpand(relationship.relationshipId)} > - }> + }> { {relationship.relationshipName} - + {relationship.groups?.length || 0} groups @@ -425,7 +454,9 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { - + Customer Tenant: {relationship.customerTenantName || relationship.customerTenantId} @@ -463,12 +494,20 @@ export const CippGDAPTraceResults = ({ data, isLoading, error }) => { + sx={{ + color: "text.secondary", + display: "block", + mb: 0.5 + }}> Additional Roles: - + {group.roles.slice(1).map((role, roleIndex) => ( { - }> + }> View Raw JSON diff --git a/src/components/CippSettings/CippGDAP/CippPathVisualization.jsx b/src/components/CippSettings/CippGDAP/CippPathVisualization.jsx index 48f6169c8f6c..3d9427962f5f 100644 --- a/src/components/CippSettings/CippGDAP/CippPathVisualization.jsx +++ b/src/components/CippSettings/CippGDAP/CippPathVisualization.jsx @@ -1,14 +1,6 @@ import React from "react"; +import { CippIcons } from "../../../utils/icon-registry" import { Box, Typography, Chip, Stack, Paper } from "@mui/material"; -import { - Person, - Group, - Security, - AccountTree, - CheckCircle, - Cancel, - Warning, -} from "@mui/icons-material"; import { CippFlowDiagram } from "./CippFlowDiagram"; /** @@ -44,7 +36,7 @@ export const CippPathVisualization = ({ id: "user", label: userDisplayName || userUPN, subLabel: "User", - icon: , + icon: , backgroundColor: `${colors.user}20`, // 20% opacity borderColor: colors.user, chips: [], @@ -53,7 +45,7 @@ export const CippPathVisualization = ({ id: "group", label: groupName || "Unknown Group", subLabel: "Security Group", - icon: isMember ? : , + icon: isMember ? : , backgroundColor: isMember ? `${colors.success}20` : `${colors.error}20`, borderColor: isMember ? colors.success : colors.error, chips: [ @@ -68,7 +60,7 @@ export const CippPathVisualization = ({ id: "role", label: roleName || "Role", subLabel: "GDAP Role", - icon: , + icon: , backgroundColor: isMember ? `${colors.success}20` : `${colors.grey}20`, borderColor: isMember ? colors.success : colors.grey, chips: [ @@ -84,7 +76,13 @@ export const CippPathVisualization = ({ return ( {relationshipName && ( - + Relationship: {relationshipName} {customerTenantName && ` → ${customerTenantName}`} @@ -110,7 +108,7 @@ export const CippPathVisualization = ({ id: "user", label: userDisplayName || userUPN, subLabel: "User", - icon: , + icon: , backgroundColor: `${colors.user}20`, borderColor: colors.user, chips: [], @@ -131,13 +129,13 @@ export const CippPathVisualization = ({ chips.push({ label: "Not Member", sx: { backgroundColor: colors.error, color: "white" }, - icon: , + icon: , }); } else if (isDirect) { chips.push({ label: "Direct", sx: { backgroundColor: colors.success, color: "white" }, - icon: , + icon: , }); } else if (isNested) { if (isLastGroup && sortedPath.length === 1) { @@ -146,7 +144,7 @@ export const CippPathVisualization = ({ chips.push({ label: "Nested", sx: { backgroundColor: colors.info, color: "white" }, - icon: , + icon: , }); } } @@ -209,9 +207,9 @@ export const CippPathVisualization = ({ subLabel: subLabel, icon: isNotMember ? ( - + ) : ( - + ), backgroundColor: `${groupColor}${isLastGroup ? "30" : "20"}`, borderColor: groupColor, @@ -227,14 +225,14 @@ export const CippPathVisualization = ({ id: "role", label: roleName || "Role", subLabel: "GDAP Role", - icon: , + icon: , backgroundColor: `${roleColor}20`, borderColor: roleColor, chips: [ { label: hasAccess ? "Has Access" : "No Access", sx: { backgroundColor: roleColor, color: "white" }, - icon: hasAccess ? : , + icon: hasAccess ? : , }, ], }); @@ -243,16 +241,29 @@ export const CippPathVisualization = ({ {relationshipName && ( - - + + Relationship: {relationshipName} {customerTenantName && ( <> - + - + Customer: {customerTenantName} diff --git a/src/components/CippSettings/CippGDAPResults.jsx b/src/components/CippSettings/CippGDAPResults.jsx index dd997acf931c..b7d69cbbcfad 100644 --- a/src/components/CippSettings/CippGDAPResults.jsx +++ b/src/components/CippSettings/CippGDAPResults.jsx @@ -1,7 +1,6 @@ import { Alert, Button, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material"; -import { Cancel, CheckCircle, Warning } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; import { CippPropertyList } from "../CippComponents/CippPropertyList"; -import { WrenchIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippDataTable } from "../CippTable/CippDataTable"; import { ApiPostCall } from "../../api/ApiCall"; @@ -43,9 +42,9 @@ export const CippGDAPResults = (props) => { results?.Results?.MissingGroups?.length > 0 || hasRoleMappingIssues ) { - setCardIcon(); + setCardIcon(); } else { - setCardIcon(); + setCardIcon(); } }, [results]); @@ -59,8 +58,8 @@ export const CippGDAPResults = (props) => { return ( <> - {type && <>{type === "Warning" ? : }} - {match && <>{issues > 0 ? : }} + {type && <>{type === "Warning" ? : }} + {match && <>{issues > 0 ? : }} {issues} @@ -185,7 +184,7 @@ export const CippGDAPResults = (props) => { - {testResult ? : } + {testResult ? : } {testResult ? test.successMessage : test.failureMessage} @@ -247,7 +246,7 @@ export const CippGDAPResults = (props) => { onClick={handleRepairRoleMappings} startIcon={ - + } > diff --git a/src/components/CippSettings/CippJitAdminSettings.jsx b/src/components/CippSettings/CippJitAdminSettings.jsx index 7068c6fb6de5..d69b7d1849e2 100644 --- a/src/components/CippSettings/CippJitAdminSettings.jsx +++ b/src/components/CippSettings/CippJitAdminSettings.jsx @@ -1,5 +1,5 @@ import { Button, Typography, Alert, Box } from "@mui/material"; -import { ClockIcon } from "@heroicons/react/24/outline"; +import { CippIcons } from "../../utils/icon-registry"; import CippButtonCard from "../CippCards/CippButtonCard"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "../CippComponents/CippApiResults"; @@ -55,7 +55,7 @@ const CippJitAdminSettings = () => { size="small" onClick={handleSave} disabled={jitChange.isPending || jitSettings.isLoading || !formControl.formState.isValid} - startIcon={} + startIcon={} > Save Settings diff --git a/src/components/CippSettings/CippLogRetentionSettings.jsx b/src/components/CippSettings/CippLogRetentionSettings.jsx index 3f949ed0c57b..a585560ed7ee 100644 --- a/src/components/CippSettings/CippLogRetentionSettings.jsx +++ b/src/components/CippSettings/CippLogRetentionSettings.jsx @@ -73,11 +73,13 @@ const CippLogRetentionSettings = () => { value={retentionDays} onChange={handleInputChange} disabled={retentionChange.isPending || retentionSetting.isLoading} - inputProps={{ min: 7, max: 365 }} error={!!error} helperText={error} sx={{ width: "120px" }} label="Days" + slotProps={{ + htmlInput: { min: 7, max: 365 } + }} /> @@ -130,10 +130,11 @@ const CippPermissionCheck = (props) => { title={ + sx={{ + alignContent: "center", + justifyContent: "space-between", + mb: 0 + }}> {type} Check {importReport?.[type] && } @@ -169,7 +170,7 @@ const CippPermissionCheck = (props) => { size="small" onClick={() => setShowAlertMessage(false)} > - + } > diff --git a/src/components/CippSettings/CippPermissionReport.jsx b/src/components/CippSettings/CippPermissionReport.jsx index 1d5794f77f39..b26a7ca747eb 100644 --- a/src/components/CippSettings/CippPermissionReport.jsx +++ b/src/components/CippSettings/CippPermissionReport.jsx @@ -8,7 +8,7 @@ import { SvgIcon, Tooltip, } from "@mui/material"; -import { Close, ContentPasteGo, FileDownload, FileUpload } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; import { ApiGetCall } from "../../api/ApiCall"; import { useDialog } from "../../hooks/use-dialog"; import { useIsMobileLayout } from "../../hooks/use-breakpoint"; @@ -196,7 +196,7 @@ export const CippPermissionReport = (props) => { onClick={createDialog.handleOpen} startIcon={ - + } > @@ -209,7 +209,7 @@ export const CippPermissionReport = (props) => { color="primary" startIcon={ - + } > @@ -229,7 +229,7 @@ export const CippPermissionReport = (props) => { onClick={handleImportFromClipboard} startIcon={ - + } > @@ -244,7 +244,7 @@ export const CippPermissionReport = (props) => { onClick={() => setImportReport(false)} endIcon={ - + } > @@ -260,7 +260,7 @@ export const CippPermissionReport = (props) => { onClick={() => setImportError(false)} endIcon={ - + } > @@ -281,7 +281,7 @@ export const CippPermissionReport = (props) => { - + @@ -289,21 +289,21 @@ export const CippPermissionReport = (props) => { sheet closing while the OS file picker is up. */} - + - + {importReport && ( setImportReport(false)} sx={{ minHeight: 48 }}> - + @@ -314,7 +314,7 @@ export const CippPermissionReport = (props) => { sx={{ minHeight: 48, color: "error.main" }} > - + diff --git a/src/components/CippSettings/CippPermissionResults.jsx b/src/components/CippSettings/CippPermissionResults.jsx index da8056906cc5..8c6deb22b133 100644 --- a/src/components/CippSettings/CippPermissionResults.jsx +++ b/src/components/CippSettings/CippPermissionResults.jsx @@ -1,7 +1,6 @@ import { Alert, Button, Link, List, ListItem, Skeleton, SvgIcon, Typography } from "@mui/material"; -import { Cancel, CheckCircle } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry"; import { CippPropertyList } from "../CippComponents/CippPropertyList"; -import { WrenchIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; import { CippDataTable } from "../CippTable/CippDataTable"; @@ -33,9 +32,9 @@ export const CippPermissionResults = (props) => { results?.Results?.MissingPermissions?.length > 0 || results?.Results?.ErrorMessages?.length > 0 ) { - setCardIcon(); + setCardIcon(); } else { - setCardIcon(); + setCardIcon(); } }, [results]); @@ -119,7 +118,7 @@ export const CippPermissionResults = (props) => { - + {message} @@ -129,7 +128,7 @@ export const CippPermissionResults = (props) => { - + {error} @@ -139,7 +138,7 @@ export const CippPermissionResults = (props) => { - + There are new permissions to apply. Please click "Details" to review and apply the new permissions. @@ -188,7 +187,7 @@ export const CippPermissionResults = (props) => { onClick={handleAddMissingPermissions} startIcon={ - + } > @@ -212,7 +211,7 @@ export const CippPermissionResults = (props) => { onClick={handleStartCPVRefresh} startIcon={ - + } > diff --git a/src/components/CippSettings/CippRoleAddEdit.jsx b/src/components/CippSettings/CippRoleAddEdit.jsx index 364dd38a70b7..f13c3b3ad343 100644 --- a/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/src/components/CippSettings/CippRoleAddEdit.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; - +import { CippIcons } from "../../utils/icon-registry"; import { Box, Button, @@ -15,16 +15,12 @@ import { ToggleButton, ToggleButtonGroup, } from "@mui/material"; - import { Grid } from "@mui/system"; import { ApiGetCall, ApiGetCallWithPagination, ApiPostCall } from "../../api/ApiCall"; import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; import { CippFormTenantSelector } from "../CippComponents/CippFormTenantSelector"; -import { Save, WarningOutlined } from "@mui/icons-material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { useForm, useFormState, useWatch } from "react-hook-form"; -import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { CippApiResults } from "../CippComponents/CippApiResults"; import cippRoles from "../../data/cipp-roles.json"; import { GroupHeader, GroupItems } from "../CippComponents/CippAutocompleteGrouping"; @@ -38,6 +34,9 @@ import { buildRuleSuggestions, } from "../../utils/permission-rules"; +// Stable reference so the ApiGetCall default doesn't retrigger effects every render. +const EMPTY_PERMISSIONS = []; + export const CippRoleAddEdit = ({ selectedRole }) => { const updatePermissions = ApiPostCall({ urlFromData: true, @@ -64,6 +63,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { BlockedEndpoints: [], IPRange: [], Permissions: {}, + AllowedRolesTemplate: null, PermissionRulesInclude: [], PermissionRulesExclude: [], }, @@ -94,6 +94,10 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const setDefaults = useWatch({ control: formControl.control, name: "Defaults" }); const selectedPermissions = useWatch({ control: formControl.control, name: "Permissions" }); const selectedEntraGroup = useWatch({ control: formControl.control, name: "EntraGroup" }); + const selectedRolesTemplate = useWatch({ + control: formControl.control, + name: "AllowedRolesTemplate", + }); const ipRanges = useWatch({ control: formControl.control, name: "IPRange" }); const includeRules = useWatch({ control: formControl.control, name: "PermissionRulesInclude" }); const excludeRules = useWatch({ control: formControl.control, name: "PermissionRulesExclude" }); @@ -111,7 +115,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { }, [baseRoleTemplate]); const { - data: apiPermissions = [], + data: apiPermissions = EMPTY_PERMISSIONS, isFetching: apiPermissionFetching, isSuccess: apiPermissionSuccess, } = ApiGetCall({ @@ -225,10 +229,15 @@ export const CippRoleAddEdit = ({ selectedRole }) => { useEffect(() => { if (selectedRole && cippRoles[selectedRole]) { - setBaseRolePermissions(getBaseRolePermissions(selectedRole)); + // Skip the setState when both the old and new value are empty (e.g. apiPermissions + // still pending) so this doesn't create a fresh {} reference on every render. + setBaseRolePermissions((prev) => { + const next = getBaseRolePermissions(selectedRole); + return Object.keys(prev).length === 0 && Object.keys(next).length === 0 ? prev : next; + }); setIsBaseRole(true); } else { - setBaseRolePermissions({}); + setBaseRolePermissions((prev) => (Object.keys(prev).length === 0 ? prev : {})); setIsBaseRole(false); } }, [selectedRole, apiPermissions]); @@ -239,7 +248,8 @@ export const CippRoleAddEdit = ({ selectedRole }) => { tenantsSuccess && selectedRole && selectedRoleState !== selectedRole) || - baseRolePermissions + // An empty {} isn't a real change — only a populated baseRolePermissions should retrigger this. + Object.keys(baseRolePermissions).length > 0 ) { setSelectedRoleState(selectedRole); const isApiRole = selectedRole === "api-role"; @@ -354,6 +364,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { BlockedEndpoints: processedBlockedEndpoints, IPRange: processedIPRanges, EntraGroup: currentPermissions?.EntraGroup, + AllowedRolesTemplate: currentPermissions?.AllowedRolesTemplate || null, PermissionRulesInclude: toRuleOptions(storedRules?.Include), PermissionRulesExclude: toRuleOptions(storedRules?.Exclude), }); @@ -485,6 +496,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { BlockedTenants: processedBlockedTenants, BlockedEndpoints: processedBlockedEndpoints, IPRange: processedIPRanges, + AllowedRolesTemplate: selectedRolesTemplate || null, }, }); }; @@ -504,15 +516,18 @@ export const CippRoleAddEdit = ({ selectedRole }) => { // The None/Read/ReadWrite radio row is wider than a phone leaves beside the object // name, so the controls drop below it there. direction={{ xs: "column", md: "row" }} - alignItems={{ xs: "flex-start", md: "center" }} - justifyContent={"space-between"} - width={"100%"} - > + sx={{ + alignItems: { xs: "flex-start", md: "center" }, + justifyContent: "space-between", + width: "100%" + }}> {obj} - + { {type} {items.map((item, idx) => ( - + {item.name} @@ -571,7 +588,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { sx={{ minWidth: "auto", p: 0.5 }} > - + )} @@ -625,7 +642,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { /> )} {selectedRole && isBaseRole && ["admin", "superadmin"].includes(selectedRole) && ( - }> + }> This is a highly privileged role and overrides any custom role restrictions. )} @@ -658,6 +675,26 @@ export const CippRoleAddEdit = ({ selectedRole }) => { creatable={false} helperText="Assigning an Entra group will automatically assign this role to all users in that group. This does not work with users invited directly to Static Web App." /> + {!isBaseRole && ( <> @@ -782,12 +819,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + width: "100%", + my: 2 + }}> @@ -979,7 +1017,14 @@ export const CippRoleAddEdit = ({ selectedRole }) => { Live result - + {currentRules.Include.map((pattern) => ( { } icon={ (ruleExpansion.includeCounts[pattern] ?? 0) === 0 ? ( - + ) : undefined } /> @@ -1007,7 +1052,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { } icon={ (ruleExpansion.excludeCounts[pattern] ?? 0) === 0 ? ( - + ) : undefined } /> @@ -1019,7 +1064,9 @@ export const CippRoleAddEdit = ({ selectedRole }) => { access and cannot be saved. ) : ( - + {ruleExpansion.matched.length} of{" "} {permissionUniverse.length} permissions granted @@ -1072,17 +1119,19 @@ export const CippRoleAddEdit = ({ selectedRole }) => { return ( } + expandIcon={} sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }} > + sx={{ + alignItems: "center", + flexWrap: "wrap", + minWidth: 0, + width: "100%" + }}> { {apiFunction.Name} {description && ( - + {description} )} @@ -1158,12 +1209,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { {!isBaseRole && ( + sx={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + width: "100%", + mb: 2 + }}> Set All Permissions @@ -1193,7 +1245,7 @@ export const CippRoleAddEdit = ({ selectedRole }) => { .sort() .map((cat, catIndex) => ( - }> + }> {cat} @@ -1317,7 +1369,9 @@ export const CippRoleAddEdit = ({ selectedRole }) => { - + diff --git a/src/components/CippSettings/CippTenantResults.jsx b/src/components/CippSettings/CippTenantResults.jsx index 3db20cd59271..fbcb40019124 100644 --- a/src/components/CippSettings/CippTenantResults.jsx +++ b/src/components/CippSettings/CippTenantResults.jsx @@ -1,5 +1,5 @@ import { CippDataTable } from "../CippTable/CippDataTable"; -import { Plumbing, Sync } from "@mui/icons-material"; +import { CippIcons } from "../../utils/icon-registry" export const CippTenantResults = (props) => { const { importReport = false } = props; @@ -56,7 +56,7 @@ export const CippTenantResults = (props) => { type: "POST", url: "/api/ExecAccessChecks?Type=Tenants", data: { TenantId: "TenantId" }, - icon: , + icon: , confirmText: "Execute the access check for the selected tenant(s)?", relatedQueryKeys: "ExecAccessChecks-Tenants", multiPost: false, @@ -66,7 +66,7 @@ export const CippTenantResults = (props) => { type: "POST", url: "/api/ExecExchangeRoleRepair", data: { TenantId: "TenantId" }, - icon: , + icon: , confirmText: "Repair Exchange roles for [TenantName]?", condition: (row) => row.OrgManagementRepairNeeded === true, }, @@ -99,3 +99,4 @@ export const CippTenantResults = (props) => { ); }; + diff --git a/src/components/CippSettings/CippUserManagement.jsx b/src/components/CippSettings/CippUserManagement.jsx index 5c11abf70345..9175c210b3ae 100644 --- a/src/components/CippSettings/CippUserManagement.jsx +++ b/src/components/CippSettings/CippUserManagement.jsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Alert, Box, @@ -17,7 +18,6 @@ import { } from "@mui/material"; import { Grid } from "@mui/system"; import { useForm } from "react-hook-form"; -import { TrashIcon, PlusIcon, PencilIcon } from "@heroicons/react/24/outline"; import { CippDataTable } from "../CippTable/CippDataTable"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { CippApiResults } from "../CippComponents/CippApiResults"; @@ -96,7 +96,7 @@ export const CippUserManagement = () => { label: "Edit Roles", icon: ( - + ), noConfirm: true, @@ -112,7 +112,7 @@ export const CippUserManagement = () => { label: "Delete User", icon: ( - + ), confirmText: "Are you sure you want to remove this user's access to CIPP?", @@ -143,24 +143,35 @@ export const CippUserManagement = () => { children: (row) => ( - + Email / UPN {row.UPN} - + Source {sourceLabel(row.Source)} - + Effective Roles - + {(row.Roles ?? []).map((role, idx) => ( ))} @@ -170,10 +181,17 @@ export const CippUserManagement = () => { <> - + Manual Roles - + {row.ManualRoles.map((role, idx) => ( ))} @@ -185,10 +203,17 @@ export const CippUserManagement = () => { <> - + Auto Roles (from Entra groups) - + {row.AutoRoles.map((role, idx) => ( ))} @@ -200,7 +225,9 @@ export const CippUserManagement = () => { <> - + Last Synced @@ -224,7 +251,7 @@ export const CippUserManagement = () => { size="small" startIcon={ - + } onClick={openAddDialog} @@ -270,10 +297,17 @@ export const CippUserManagement = () => { {bulkEditUsers && ( - + Selected Users - + {bulkEditUsers.map((u, idx) => ( ))} diff --git a/src/components/CippSettings/CippVersionProperties.jsx b/src/components/CippSettings/CippVersionProperties.jsx index 689f959b85ec..edc6ff830792 100644 --- a/src/components/CippSettings/CippVersionProperties.jsx +++ b/src/components/CippSettings/CippVersionProperties.jsx @@ -1,6 +1,6 @@ import { Box, Button, Stack, SvgIcon } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; -import { CheckCircle, ContentCopy, SystemUpdateAlt, Warning } from "@mui/icons-material"; import { ApiGetCall } from "../../api/ApiCall"; import { useEffect, useState } from "react"; @@ -36,7 +36,7 @@ const CippVersionProperties = () => { return ( - {outOfDate === true ? : } + {outOfDate === true ? : } v{version}{" "} {outOfDate === true ? `(v${availableVersion} is available)` : ""} @@ -96,7 +96,7 @@ const CippVersionProperties = () => { @@ -110,7 +110,7 @@ const CippVersionProperties = () => { }} > - + Check For Updates diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx index 782d383e6920..857c1e49f1f7 100644 --- a/src/components/CippStandards/CippStandardAccordion.jsx +++ b/src/components/CippStandards/CippStandardAccordion.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useMemo } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Card, Stack, @@ -21,19 +22,6 @@ import { Checkbox, ListItemText, } from "@mui/material"; -import { - ExpandMore as ExpandMoreIcon, - Delete, - Add, - Public, - Search, - Close, - FilterAlt, - NotificationImportant, - Assignment, - Construction, - Warning, -} from "@mui/icons-material"; import { Grid } from "@mui/system"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { useWatch, useFormState } from "react-hook-form"; @@ -244,6 +232,12 @@ const CippStandardAccordion = ({ } } + // Number fields now carry a real 0 (or null when empty) instead of a string - "0" was + // truthy so !!fieldValue passed, but 0 is falsy and would wrongly read as unfilled. + if (component.type === "number") { + return fieldValue !== undefined && fieldValue !== null && fieldValue !== ""; + } + // For other field types return !!fieldValue; }) ?? true; @@ -561,15 +555,20 @@ const CippStandardAccordion = ({ - + alignItems: { xs: "flex-start", sm: "center" } + }}> + - + ), endAdornment: searchQuery && ( @@ -605,7 +604,7 @@ const CippStandardAccordion = ({ }} aria-label="Clear search" > - + @@ -617,7 +616,7 @@ const CippStandardAccordion = ({ diff --git a/src/components/CippWizard/CippWizardActionsRow.jsx b/src/components/CippWizard/CippWizardActionsRow.jsx index 0641f9dbb199..54e6c87ffced 100644 --- a/src/components/CippWizard/CippWizardActionsRow.jsx +++ b/src/components/CippWizard/CippWizardActionsRow.jsx @@ -23,19 +23,21 @@ export const CippWizardActionsRow = (props) => { + + ...sx + }}> {children} ); diff --git a/src/components/CippWizard/CippWizardAutopilotImport.jsx b/src/components/CippWizard/CippWizardAutopilotImport.jsx index 33be2733edf5..95a7e814dc96 100644 --- a/src/components/CippWizard/CippWizardAutopilotImport.jsx +++ b/src/components/CippWizard/CippWizardAutopilotImport.jsx @@ -13,10 +13,10 @@ import { Paper, IconButton, } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' import { CippWizardStepButtons } from './CippWizardStepButtons' import { CippDataTable } from '../CippTable/CippDataTable' import { useWatch } from 'react-hook-form' -import { Delete, FileDownload, Upload, Add } from '@mui/icons-material' import { useEffect, useState } from 'react' import React from 'react' import { useIsMobileLayout } from '../../hooks/use-breakpoint' @@ -330,7 +330,7 @@ export const CippWizardAutopilotImport = (props) => { const actions = [ { - icon: , + icon: , label: 'Delete Row', confirmText: 'Are you sure you want to delete this row?', customFunction: handleRemoveItem, @@ -354,7 +354,7 @@ export const CippWizardAutopilotImport = (props) => { fields.map((f) => f.propertyName).join(',') + '\n' )}`} download={`${fileName}.csv`} - startIcon={} + startIcon={} size="small" > Download Template @@ -367,13 +367,13 @@ export const CippWizardAutopilotImport = (props) => { onChange={handleFileSelect} /> - @@ -466,10 +466,11 @@ export const CippWizardAutopilotImport = (props) => { + sx={{ + alignItems: "center", + mb: 2 + }}> {rowNumber} Device {rowIndex + 1} @@ -480,12 +481,12 @@ export const CippWizardAutopilotImport = (props) => { color="error" aria-label={`Remove device ${rowIndex + 1}`} > - + {fieldInputs} - ) + ); } return ( @@ -588,5 +589,5 @@ export const CippWizardAutopilotImport = (props) => { formControl={formControl} /> - ) + ); } diff --git a/src/components/CippWizard/CippWizardAutopilotTypeSelection.jsx b/src/components/CippWizard/CippWizardAutopilotTypeSelection.jsx index 1c2bf3ae554a..65cec5b4230e 100644 --- a/src/components/CippWizard/CippWizardAutopilotTypeSelection.jsx +++ b/src/components/CippWizard/CippWizardAutopilotTypeSelection.jsx @@ -6,12 +6,9 @@ import { SvgIcon, Typography, } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' import { useState, useEffect } from 'react' import { CippWizardStepButtons } from './CippWizardStepButtons' -import { - IdentificationIcon, - RocketLaunchIcon, -} from '@heroicons/react/24/outline' export const CippWizardAutopilotTypeSelection = (props) => { const { onNextStep, formControl, currentStep, onPreviousStep } = props @@ -54,14 +51,14 @@ export const CippWizardAutopilotTypeSelection = (props) => { label: 'Windows Autopilot', description: 'Upload devices to Windows Autopilot using their serial number, product ID or hardware hash.', - icon: , + icon: , }, { value: 'devicePrep', label: 'Device Preparation (Corporate Identifiers)', description: 'Upload corporate device identifiers (manufacturer, model and serial number) so devices are recognized as corporate-owned and can enroll using Windows Autopilot device preparation.', - icon: , + icon: , }, ] @@ -69,7 +66,9 @@ export const CippWizardAutopilotTypeSelection = (props) => { Select Deployment Type - + Choose how you want to register the devices for this tenant. @@ -94,7 +93,9 @@ export const CippWizardAutopilotTypeSelection = (props) => { }} > - + { {option.label} - + {option.description} - ) + ); })} { formControl={formControl} /> - ) + ); } export default CippWizardAutopilotTypeSelection diff --git a/src/components/CippWizard/CippWizardBulkOptions.jsx b/src/components/CippWizard/CippWizardBulkOptions.jsx deleted file mode 100644 index 43fcaf1171b6..000000000000 --- a/src/components/CippWizard/CippWizardBulkOptions.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Stack } from "@mui/material"; -import { Grid } from "@mui/system"; -import CippWizardStepButtons from "./CippWizardStepButtons"; -import CippFormComponent from "../CippComponents/CippFormComponent"; -import countryList from "../../data/countryList.json"; -import { CippFormLicenseSelector } from "../CippComponents/CippFormLicenseSelector"; -export const CippWizardBulkOptions = (props) => { - const { postUrl, formControl, onPreviousStep, onNextStep, currentStep } = props; - - return ( - - <> - - - ({ - label: Name, - value: Code, - }))} - formControl={formControl} - /> - - - - - - - - - ); -}; diff --git a/src/components/CippWizard/CippWizardCSVImport.jsx b/src/components/CippWizard/CippWizardCSVImport.jsx index 9d12eb088874..41ce19ee3d58 100644 --- a/src/components/CippWizard/CippWizardCSVImport.jsx +++ b/src/components/CippWizard/CippWizardCSVImport.jsx @@ -1,10 +1,10 @@ import { Button, Link, Stack, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry" import { Grid } from "@mui/system"; import { CippWizardStepButtons } from "./CippWizardStepButtons"; import CippFormComponent from "../CippComponents/CippFormComponent"; import { CippDataTable } from "../CippTable/CippDataTable"; import { useWatch } from "react-hook-form"; -import { Delete } from "@mui/icons-material"; import { useEffect, useState } from "react"; import { getCippTranslation } from "../../utils/get-cipp-translation"; @@ -55,7 +55,7 @@ export const CippWizardCSVImport = (props) => { const actions = [ { - icon: , + icon: , label: "Delete Row", confirmText: "Are you sure you want to delete this row?", customFunction: handleRemoveItem, diff --git a/src/components/CippWizard/CippWizardConfirmation.jsx b/src/components/CippWizard/CippWizardConfirmation.jsx index 3f8ecd288590..379386d0b63d 100644 --- a/src/components/CippWizard/CippWizardConfirmation.jsx +++ b/src/components/CippWizard/CippWizardConfirmation.jsx @@ -1,4 +1,13 @@ -import { Card, Stack, Typography } from "@mui/material"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Card, + Stack, + Typography, +} from "@mui/material"; +import { CippIcons } from "../../utils/icon-registry"; +import { useState } from "react"; import { Grid } from "@mui/system"; import { PropertyList } from "../property-list"; import { PropertyListItem } from "../property-list-item"; @@ -14,6 +23,7 @@ export const CippWizardConfirmation = (props) => { onPreviousStep, onNextStep, currentStep, + jobProgress, // Optional live progress polling after submit, see CippApiResults columns = 2 // Default to 2 columns for backward compatibility } = props; @@ -104,18 +114,31 @@ export const CippWizardConfirmation = (props) => { const gridSize = getGridSize(); + // The summary folds away on submit so the results and any live progress take the space. + const [summaryOpen, setSummaryOpen] = useState(true); + return ( {filteredEntries.length === 0 ? ( - + You've completed the steps in this wizard. Hit submit to save your changes. ) : ( - + setSummaryOpen(open)} + > + }> + Selected options + + {columnEntries.map((columnData, index) => ( @@ -131,7 +154,8 @@ export const CippWizardConfirmation = (props) => { ))} - + + )} { onNextStep={onNextStep} formControl={formControl} noSubmitButton={formValues?.noSubmitButton} + jobProgress={jobProgress} + onSubmit={() => setSummaryOpen(false)} /> ); diff --git a/src/components/CippWizard/CippWizardDevicePrepImport.jsx b/src/components/CippWizard/CippWizardDevicePrepImport.jsx index 157b525977b0..3a78fe4bff8b 100644 --- a/src/components/CippWizard/CippWizardDevicePrepImport.jsx +++ b/src/components/CippWizard/CippWizardDevicePrepImport.jsx @@ -13,11 +13,11 @@ import { Paper, IconButton, } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' import { CippWizardStepButtons } from './CippWizardStepButtons' import CippFormComponent from '../CippComponents/CippFormComponent' import { CippDataTable } from '../CippTable/CippDataTable' import { useWatch } from 'react-hook-form' -import { Delete, FileDownload, Upload, Add } from '@mui/icons-material' import { useEffect, useState } from 'react' import React from 'react' import { useIsMobileLayout } from '../../hooks/use-breakpoint' @@ -317,7 +317,7 @@ export const CippWizardDevicePrepImport = (props) => { const actions = [ { - icon: , + icon: , label: 'Delete Row', confirmText: 'Are you sure you want to delete this row?', customFunction: handleRemoveItem, @@ -353,7 +353,7 @@ export const CippWizardDevicePrepImport = (props) => { fields.map((f) => f.propertyName).join(',') + '\n' )}`} download={`${fileName}.csv`} - startIcon={} + startIcon={} size="small" > Download Template @@ -366,14 +366,14 @@ export const CippWizardDevicePrepImport = (props) => { onChange={handleFileSelect} />
  • - ) + ); } BulkActionsMenu.propTypes = { diff --git a/src/components/confirmation-dialog.js b/src/components/confirmation-dialog.jsx similarity index 87% rename from src/components/confirmation-dialog.js rename to src/components/confirmation-dialog.jsx index 1ec98341e0a6..ff719e2f03f8 100644 --- a/src/components/confirmation-dialog.js +++ b/src/components/confirmation-dialog.jsx @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; -import ExclamationCircleIcon from '@heroicons/react/24/outline/ExclamationCircleIcon'; -import ExclamationTriangleIcon from '@heroicons/react/24/outline/ExclamationTriangleIcon'; +import { CippIcons } from '../utils/icon-registry'; import { Button, CircularProgress, @@ -19,7 +18,7 @@ const iconMap = { color="error" fontSize="large" > - + ), warning: ( @@ -27,7 +26,7 @@ const iconMap = { color="warning" fontSize="large" > - + ), info: ( @@ -35,7 +34,7 @@ const iconMap = { color="info" fontSize="large" > - + ) }; @@ -72,9 +71,11 @@ export const ConfirmationDialog = (props) => { {...other}> {icon} diff --git a/src/components/csvExportButton.js b/src/components/csvExportButton.jsx similarity index 97% rename from src/components/csvExportButton.js rename to src/components/csvExportButton.jsx index 3b619defc197..408e89f75eff 100644 --- a/src/components/csvExportButton.js +++ b/src/components/csvExportButton.jsx @@ -1,5 +1,5 @@ -import { BackupTableTwoTone } from '@mui/icons-material' import { IconButton, Tooltip } from '@mui/material' +import { CippIcons } from '../utils/icon-registry' import { mkConfig, generateCsv, download } from 'export-to-csv' import { getCippFormatting } from '../utils/get-cipp-formatting' import { SKIP_RECURSION_KEYS } from '../utils/skip-recursion-keys' @@ -105,7 +105,7 @@ export const CSVExportButton = (props) => { onClick={() => exportRowsToCsv({ rows, columns, reportName, columnVisibility })} {...other} > - +
    diff --git a/src/components/file-dropzone.js b/src/components/file-dropzone.jsx similarity index 83% rename from src/components/file-dropzone.js rename to src/components/file-dropzone.jsx index 166f57cce23f..5ae36cecd275 100644 --- a/src/components/file-dropzone.js +++ b/src/components/file-dropzone.jsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; +import { CippIcons } from '../utils/icon-registry'; import { useDropzone } from 'react-dropzone'; -import ArrowUpOnSquareIcon from '@heroicons/react/24/outline/ArrowUpOnSquareIcon'; import { Avatar, Box, SvgIcon, Typography } from '@mui/material'; export const FileDropzone = (props) => { @@ -42,25 +42,27 @@ export const FileDropzone = (props) => { ...sx }} {...getRootProps()}> + {/* No backgroundColor: the theme's Avatar override already picks a surface per mode, + and the hard-coded neutral.200 was an off-white disc on a dark card. */} - + {caption && ( + sx={{ + color: "text.secondary", + mt: 1 + }}> {caption} )} diff --git a/src/components/filter-dialog-item.js b/src/components/filter-dialog-item.jsx similarity index 93% rename from src/components/filter-dialog-item.js rename to src/components/filter-dialog-item.jsx index 82c1ef3c71b1..cc5a50caf551 100644 --- a/src/components/filter-dialog-item.js +++ b/src/components/filter-dialog-item.jsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; +import { CippIcons } from '../utils/icon-registry'; import PropTypes from 'prop-types'; -import PlusIcon from '@heroicons/react/24/outline/PlusIcon'; import { Button, FilledInput, MenuItem, Select, Stack, TextField, Typography } from '@mui/material'; import { DatePicker } from '@mui/x-date-pickers'; @@ -40,9 +40,11 @@ export const FilterDialogItem = (props) => { { {/* ── Job Queue ── */} {isImported && importedJobs ? ( - + {importedJobs.length === 0 ? ( - + No job data was captured in this export @@ -894,14 +1046,15 @@ const Page = () => { } + avatar={} action={ - + {!isImported && ( - + )} @@ -920,6 +1073,9 @@ const Page = () => { } + slotProps={{ + title: { variant: "h6" } + }} /> @@ -929,7 +1085,7 @@ const Page = () => { data={historyData} rangeMinutes={historyRange} title="Worker Utilization %" - icon={} + icon={} > {(data, t) => ( @@ -955,7 +1111,7 @@ const Page = () => { data={historyData} rangeMinutes={historyRange} title="Invocations / Interval" - icon={} + icon={} > {(data, t) => ( @@ -984,7 +1140,7 @@ const Page = () => { data={historyData} rangeMinutes={historyRange} title="Memory Usage (MB)" - icon={} + icon={} > {(data, t) => ( @@ -1013,7 +1169,7 @@ const Page = () => { data={historyData} rangeMinutes={historyRange} title="CPU Usage %" - icon={} + icon={} > {(data, t) => ( @@ -1043,7 +1199,7 @@ const Page = () => { data={historyData} rangeMinutes={historyRange} title="Job Queue Depth" - icon={} + icon={} > {(data, t) => ( @@ -1069,7 +1225,7 @@ const Page = () => { data={historyData} rangeMinutes={historyRange} title="Faults & Avg Duration" - icon={} + icon={} > {(data, t) => ( @@ -1137,7 +1293,6 @@ const Page = () => { { size="small" /> } + slotProps={{ + title: { variant: "h6" } + }} /> 0 ? 2 : "12px !important" }}> {/* Capacity bar */} - - + + Capacity - + {trackedMB} / {maxMB} MB ({Math.round(memPct)}%) @@ -1170,15 +1337,22 @@ const Page = () => { {cacheStats.map((s) => { const cell = ( - + {s.k} + sx={{ + fontWeight: 600, + lineHeight: 1.3 + }}> {s.v} @@ -1240,3 +1414,4 @@ Page.getLayout = (page) => ( ); export default Page; + diff --git a/src/pages/cipp/advanced/diagnostics.js b/src/pages/cipp/advanced/diagnostics.jsx similarity index 84% rename from src/pages/cipp/advanced/diagnostics.js rename to src/pages/cipp/advanced/diagnostics.jsx index 962eced7a27a..d5885e188d94 100644 --- a/src/pages/cipp/advanced/diagnostics.js +++ b/src/pages/cipp/advanced/diagnostics.jsx @@ -1,16 +1,11 @@ -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../layouts/index"; +import { CippIcons } from "../../../utils/icon-registry"; import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; import CippDiagnosticsFilter from "../../../components/CippTable/CippDiagnosticsFilter"; import { CippPropertyListCard } from "../../../components/CippCards/CippPropertyListCard"; import { useState } from "react"; import { Grid } from "@mui/system"; import { Box, Typography, Chip, Stack, Divider } from "@mui/material"; -import { - Error as ErrorIcon, - Warning as WarningIcon, - Info as InfoIcon, - BugReport as DebugIcon, -} from "@mui/icons-material"; const Page = () => { const [apiFilter, setApiFilter] = useState({ query: "", presetDisplayName: null, columns: null }); @@ -49,18 +44,18 @@ const Page = () => { switch (levelStr) { case "error": case "4": - return { icon: , color: "error", label: "Error" }; + return { icon: , color: "error", label: "Error" }; case "warning": case "3": - return { icon: , color: "warning", label: "Warning" }; + return { icon: , color: "warning", label: "Warning" }; case "debug": case "0": - return { icon: , color: "default", label: "Debug" }; + return { icon: , color: "default", label: "Debug" }; case "verbose": case "1": - return { icon: , color: "info", label: "Verbose" }; + return { icon: , color: "info", label: "Verbose" }; default: - return { icon: , color: "info", label: "Information" }; + return { icon: , color: "info", label: "Information" }; } }; @@ -105,14 +100,22 @@ const Page = () => { {/* Header with severity and timestamp */} - + - + {new Date(timestamp).toLocaleString()} @@ -121,7 +124,13 @@ const Page = () => { {/* Message */} - + Message {isJson && ( @@ -217,14 +226,22 @@ const Page = () => { {/* Header */} - + - + {new Date(timestamp).toLocaleString()} @@ -238,13 +255,17 @@ const Page = () => { - + {isTaskCompleted ? "Task Name" : "Standard"} {taskName} - + Command @@ -252,14 +273,18 @@ const Page = () => { - + Tenant {tenant} {count && ( - + Count {count} @@ -267,7 +292,9 @@ const Page = () => { )} {totalDuration && ( - + Total Duration (ms) {totalDuration.toFixed(2)} @@ -275,7 +302,9 @@ const Page = () => { )} {avgDuration && ( - + Average Duration (ms) {avgDuration.toFixed(2)} @@ -283,7 +312,9 @@ const Page = () => { )} {maxDuration && ( - + Max Duration (ms) {maxDuration.toFixed(2)} @@ -365,7 +396,13 @@ const Page = () => { - + @@ -397,3 +434,4 @@ const Page = () => { Page.getLayout = (page) => {page}; export default Page; + diff --git a/src/pages/cipp/advanced/exchange-cmdlets.js b/src/pages/cipp/advanced/exchange-cmdlets.jsx similarity index 97% rename from src/pages/cipp/advanced/exchange-cmdlets.js rename to src/pages/cipp/advanced/exchange-cmdlets.jsx index 9e6e61bcd379..ca1a9200fc68 100644 --- a/src/pages/cipp/advanced/exchange-cmdlets.js +++ b/src/pages/cipp/advanced/exchange-cmdlets.jsx @@ -1,4 +1,5 @@ -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../layouts/index"; +import { CippIcons } from "../../../utils/icon-registry" import { Alert, Button, @@ -17,7 +18,6 @@ import { ApiPostCall } from "../../../api/ApiCall"; import CippButtonCard from "../../../components/CippCards/CippButtonCard"; import { CippDataTable } from "../../../components/CippTable/CippDataTable"; import { useState, useEffect } from "react"; -import { Search, Close } from "@mui/icons-material"; import { CippFormTenantSelector } from "../../../components/CippComponents/CippFormTenantSelector"; import { CippHead } from "../../../components/CippComponents/CippHead"; @@ -131,7 +131,7 @@ const Page = () => { {/* Submit Button */} - @@ -154,7 +154,7 @@ const Page = () => { onClick={() => setDialogOpen(false)} sx={{ position: "absolute", right: 8, top: 8 }} > - + diff --git a/src/pages/cipp/advanced/super-admin/cipp-roles/add.js b/src/pages/cipp/advanced/super-admin/cipp-roles/add.js deleted file mode 100644 index 1ed58660a0b7..000000000000 --- a/src/pages/cipp/advanced/super-admin/cipp-roles/add.js +++ /dev/null @@ -1,18 +0,0 @@ -// Legacy redirect: this page moved to /cipp/advanced/authentication/cipp-roles/add when the -// Super Admin area was split into Super Admin / Container Management / Authentication. -// Safe to delete once bookmarks and docs links have aged out. -import { useEffect } from "react"; -import { useRouter } from "next/router"; - -const Page = () => { - const router = useRouter(); - - useEffect(() => { - if (!router.isReady) return; - router.replace({ pathname: "/cipp/advanced/authentication/cipp-roles/add", query: router.query }); - }, [router.isReady]); - - return null; -}; - -export default Page; diff --git a/src/pages/cipp/advanced/super-admin/cipp-roles/edit.js b/src/pages/cipp/advanced/super-admin/cipp-roles/edit.js deleted file mode 100644 index 409f8d0c4535..000000000000 --- a/src/pages/cipp/advanced/super-admin/cipp-roles/edit.js +++ /dev/null @@ -1,18 +0,0 @@ -// Legacy redirect: this page moved to /cipp/advanced/authentication/cipp-roles/edit when the -// Super Admin area was split into Super Admin / Container Management / Authentication. -// Safe to delete once bookmarks and docs links have aged out. -import { useEffect } from "react"; -import { useRouter } from "next/router"; - -const Page = () => { - const router = useRouter(); - - useEffect(() => { - if (!router.isReady) return; - router.replace({ pathname: "/cipp/advanced/authentication/cipp-roles/edit", query: router.query }); - }, [router.isReady]); - - return null; -}; - -export default Page; diff --git a/src/pages/cipp/advanced/super-admin/cipp-roles/index.js b/src/pages/cipp/advanced/super-admin/cipp-roles/index.js deleted file mode 100644 index 262a83a66f61..000000000000 --- a/src/pages/cipp/advanced/super-admin/cipp-roles/index.js +++ /dev/null @@ -1,18 +0,0 @@ -// Legacy redirect: this page moved to /cipp/advanced/authentication/cipp-roles when the -// Super Admin area was split into Super Admin / Container Management / Authentication. -// Safe to delete once bookmarks and docs links have aged out. -import { useEffect } from "react"; -import { useRouter } from "next/router"; - -const Page = () => { - const router = useRouter(); - - useEffect(() => { - if (!router.isReady) return; - router.replace({ pathname: "/cipp/advanced/authentication/cipp-roles", query: router.query }); - }, [router.isReady]); - - return null; -}; - -export default Page; diff --git a/src/pages/cipp/advanced/super-admin/function-offloading.js b/src/pages/cipp/advanced/super-admin/function-offloading.jsx similarity index 96% rename from src/pages/cipp/advanced/super-admin/function-offloading.js rename to src/pages/cipp/advanced/super-admin/function-offloading.jsx index d758a7168dab..d3a55da7f0c7 100644 --- a/src/pages/cipp/advanced/super-admin/function-offloading.js +++ b/src/pages/cipp/advanced/super-admin/function-offloading.jsx @@ -1,5 +1,6 @@ import { TabbedLayout } from "../../../../layouts/TabbedLayout"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { CippIcons } from "../../../../utils/icon-registry" +import { Layout as DashboardLayout } from "../../../../layouts/index"; import tabOptions from "./tabOptions"; import CippTablePage from "../../../../components/CippComponents/CippTablePage"; import { Alert, Button, FormControlLabel, Link, Stack, Switch, Typography } from "@mui/material"; @@ -7,7 +8,6 @@ import { Grid } from "@mui/system"; import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; import { useEffect, useState } from "react"; import NextLink from "next/link"; -import { TrashIcon } from "@heroicons/react/24/outline"; import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; const Page = () => { @@ -70,7 +70,9 @@ const Page = () => { return ( + { actions={[ { label: "Delete Offloading Entry", - icon: , + icon: , url: "/api/ExecAzBobbyTables", type: "POST", customFunction: handleDeleteOffloadEntry, diff --git a/src/pages/cipp/advanced/super-admin/jit-admin-settings.js b/src/pages/cipp/advanced/super-admin/jit-admin-settings.jsx similarity index 99% rename from src/pages/cipp/advanced/super-admin/jit-admin-settings.js rename to src/pages/cipp/advanced/super-admin/jit-admin-settings.jsx index 514b5b097c07..1d372ef520a2 100644 --- a/src/pages/cipp/advanced/super-admin/jit-admin-settings.js +++ b/src/pages/cipp/advanced/super-admin/jit-admin-settings.jsx @@ -1,5 +1,5 @@ import { TabbedLayout } from "../../../../layouts/TabbedLayout"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; import tabOptions from "./tabOptions"; import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; import { useForm } from "react-hook-form"; diff --git a/src/pages/cipp/advanced/super-admin/tenant-mode.js b/src/pages/cipp/advanced/super-admin/tenant-mode.jsx similarity index 99% rename from src/pages/cipp/advanced/super-admin/tenant-mode.js rename to src/pages/cipp/advanced/super-admin/tenant-mode.jsx index 43174776a30e..94a2bed07eed 100644 --- a/src/pages/cipp/advanced/super-admin/tenant-mode.js +++ b/src/pages/cipp/advanced/super-admin/tenant-mode.jsx @@ -1,5 +1,5 @@ import { TabbedLayout } from "../../../../layouts/TabbedLayout"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; import tabOptions from "./tabOptions"; import CippFormPage from "../../../../components/CippFormPages/CippFormPage"; import { useForm } from "react-hook-form"; diff --git a/src/pages/cipp/advanced/super-admin/time-settings.js b/src/pages/cipp/advanced/super-admin/time-settings.jsx similarity index 99% rename from src/pages/cipp/advanced/super-admin/time-settings.js rename to src/pages/cipp/advanced/super-admin/time-settings.jsx index 2fa73742f06c..ef14bbb80317 100644 --- a/src/pages/cipp/advanced/super-admin/time-settings.js +++ b/src/pages/cipp/advanced/super-admin/time-settings.jsx @@ -1,5 +1,5 @@ import { TabbedLayout } from '../../../../layouts/TabbedLayout' -import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { Layout as DashboardLayout } from '../../../../layouts/index' import { useForm } from 'react-hook-form' import { Grid } from '@mui/system' import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' diff --git a/src/pages/cipp/advanced/table-maintenance.js b/src/pages/cipp/advanced/table-maintenance.jsx similarity index 94% rename from src/pages/cipp/advanced/table-maintenance.js rename to src/pages/cipp/advanced/table-maintenance.jsx index a6f83e4dfe96..0f1916c31dc2 100644 --- a/src/pages/cipp/advanced/table-maintenance.js +++ b/src/pages/cipp/advanced/table-maintenance.jsx @@ -1,4 +1,5 @@ -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../layouts/index"; +import { CippIcons } from "../../../utils/icon-registry"; import { useEffect, useState } from "react"; import { ApiPostCall } from "../../../api/ApiCall"; import { CippPropertyListCard } from "../../../components/CippCards/CippPropertyListCard"; // Fixed import @@ -21,8 +22,6 @@ import { Select, Alert, } from "@mui/material"; -import { MagnifyingGlassIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline"; -import { Add, AddCircle, RemoveCircle, Sync, WarningAmber } from "@mui/icons-material"; import CippFormComponent from "../../../components/CippComponents/CippFormComponent"; import { useForm, useWatch } from "react-hook-form"; import { CippApiDialog } from "../../../components/CippComponents/CippApiDialog"; @@ -71,10 +70,13 @@ const CustomAddEditRowDialog = ({ formControl, open, onClose, onSubmit, defaultV direction={{ xs: "column", md: "row" }} spacing={0.5} key={index} - alignItems={{ xs: "stretch", md: "center" }} - width="100%" - > - + sx={{ + alignItems: { xs: "stretch", md: "center" }, + width: "100%" + }}> + - + - + removeField(index)}> - + ))} )} - @@ -251,9 +257,15 @@ const Page = () => { { label: "", value: ( - + - + @@ -311,7 +323,7 @@ const Page = () => { onClick={addTableDialog.handleOpen} // Open add table dialog > - + @@ -323,7 +335,7 @@ const Page = () => { onClick={handleTableRefresh} > - + @@ -412,7 +424,7 @@ const Page = () => { }} // Open add/edit row dialog startIcon={ - + } > @@ -425,7 +437,7 @@ const Page = () => { onClick={deleteTableDialog.handleOpen} // Open delete table dialog startIcon={ - + } > @@ -439,7 +451,7 @@ const Page = () => { type: "POST", icon: ( - + ), customFunction: (row) => { @@ -467,7 +479,7 @@ const Page = () => { type: "POST", icon: ( - + ), url: apiUrl, @@ -530,7 +542,7 @@ const Page = () => { url: apiUrl, confirmText: ( - + Are you sure you want to delete this table? This is a destructive action that cannot be undone. diff --git a/src/pages/cipp/advanced/timers.js b/src/pages/cipp/advanced/timers.jsx similarity index 95% rename from src/pages/cipp/advanced/timers.js rename to src/pages/cipp/advanced/timers.jsx index 669500a24d80..0f008ba9dc5a 100644 --- a/src/pages/cipp/advanced/timers.js +++ b/src/pages/cipp/advanced/timers.jsx @@ -1,6 +1,6 @@ -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../layouts/index"; +import { CippIcons } from "../../../utils/icon-registry" import { SvgIcon, Button } from "@mui/material"; -import { Refresh, PlayArrow } from "@mui/icons-material"; import { ApiPostCall } from "../../../api/ApiCall"; import { useEffect, useState } from "react"; import { CippTablePage } from "../../../components/CippComponents/CippTablePage"; @@ -53,7 +53,7 @@ const Page = () => { onClick={createDialog.handleOpen} startIcon={ - + } > @@ -81,7 +81,7 @@ const Page = () => { data: { FunctionName: "Command", Parameters: "Parameters" }, confirmText: "Do you want to run this task now?", allowResubmit: true, - icon: , + icon: , }, ]} /> diff --git a/src/pages/cipp/custom-data/directory-extensions/add.js b/src/pages/cipp/custom-data/directory-extensions/add.jsx similarity index 99% rename from src/pages/cipp/custom-data/directory-extensions/add.js rename to src/pages/cipp/custom-data/directory-extensions/add.jsx index 0b7d2e11e6ee..7d74995eee85 100644 --- a/src/pages/cipp/custom-data/directory-extensions/add.js +++ b/src/pages/cipp/custom-data/directory-extensions/add.jsx @@ -1,4 +1,4 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; import { useForm, useFormState } from "react-hook-form"; import { ApiPostCall } from "../../../../api/ApiCall"; import { useRouter } from "next/router"; diff --git a/src/pages/cipp/custom-data/directory-extensions/index.js b/src/pages/cipp/custom-data/directory-extensions/index.jsx similarity index 94% rename from src/pages/cipp/custom-data/directory-extensions/index.js rename to src/pages/cipp/custom-data/directory-extensions/index.jsx index 5a223007c863..b5241fbd37e7 100644 --- a/src/pages/cipp/custom-data/directory-extensions/index.js +++ b/src/pages/cipp/custom-data/directory-extensions/index.jsx @@ -1,11 +1,10 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; +import { CippIcons } from "../../../../utils/icon-registry" import { TabbedLayout } from "../../../../layouts/TabbedLayout"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { Alert, Button, Link, SvgIcon } from "@mui/material"; -import { Add } from "@mui/icons-material"; import tabOptions from "../tabOptions"; import NextLink from "next/link"; -import { TrashIcon } from "@heroicons/react/24/outline"; const Page = () => { const pageTitle = "Directory Extensions"; @@ -21,7 +20,7 @@ const Page = () => { const actions = [ { label: "Delete Directory Extension", - icon: , + icon: , url: "/api/ExecCustomData?Action=DeleteDirectoryExtension", type: "POST", data: { @@ -68,7 +67,7 @@ const Page = () => { href="/cipp/custom-data/directory-extensions/add" > - + Add Directory Extension diff --git a/src/pages/cipp/custom-data/mappings/add.js b/src/pages/cipp/custom-data/mappings/add.jsx similarity index 99% rename from src/pages/cipp/custom-data/mappings/add.js rename to src/pages/cipp/custom-data/mappings/add.jsx index 601fd777aa9e..ed6cc76a0abc 100644 --- a/src/pages/cipp/custom-data/mappings/add.js +++ b/src/pages/cipp/custom-data/mappings/add.jsx @@ -1,4 +1,4 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; import { useForm, useFormState } from "react-hook-form"; import { ApiPostCall } from "../../../../api/ApiCall"; import { useRouter } from "next/router"; diff --git a/src/pages/cipp/custom-data/mappings/edit.js b/src/pages/cipp/custom-data/mappings/edit.jsx similarity index 99% rename from src/pages/cipp/custom-data/mappings/edit.js rename to src/pages/cipp/custom-data/mappings/edit.jsx index ca48da77ed14..41ac9e2dece1 100644 --- a/src/pages/cipp/custom-data/mappings/edit.js +++ b/src/pages/cipp/custom-data/mappings/edit.jsx @@ -1,4 +1,4 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; import { useForm, useFormState } from "react-hook-form"; import { useRouter } from "next/router"; import { useEffect } from "react"; diff --git a/src/pages/cipp/custom-data/mappings/index.js b/src/pages/cipp/custom-data/mappings/index.jsx similarity index 91% rename from src/pages/cipp/custom-data/mappings/index.js rename to src/pages/cipp/custom-data/mappings/index.jsx index f25c17fd83e1..1dbc4deed822 100644 --- a/src/pages/cipp/custom-data/mappings/index.js +++ b/src/pages/cipp/custom-data/mappings/index.jsx @@ -1,11 +1,10 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; +import { CippIcons } from "../../../../utils/icon-registry" import { TabbedLayout } from "../../../../layouts/TabbedLayout"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { Alert, Button, SvgIcon, Typography } from "@mui/material"; -import { Add } from "@mui/icons-material"; import tabOptions from "../tabOptions"; import NextLink from "next/link"; -import { TrashIcon, PencilIcon } from "@heroicons/react/24/outline"; const Page = () => { const pageTitle = "Custom Data Mappings"; @@ -23,12 +22,13 @@ const Page = () => { const actions = [ { label: "Edit Mapping", - icon: , + pinned: true, + icon: , link: "/cipp/custom-data/mappings/edit?id=[id]", }, { label: "Delete Mapping", - icon: , + icon: , url: "/api/ExecCustomData?Action=DeleteMapping", type: "POST", data: { @@ -59,7 +59,7 @@ const Page = () => { href="/cipp/custom-data/mappings/add" > - + Add Mapping diff --git a/src/pages/cipp/custom-data/schema-extensions/add.js b/src/pages/cipp/custom-data/schema-extensions/add.jsx similarity index 91% rename from src/pages/cipp/custom-data/schema-extensions/add.js rename to src/pages/cipp/custom-data/schema-extensions/add.jsx index 4d8fb8760c80..ef6a298b8bcb 100644 --- a/src/pages/cipp/custom-data/schema-extensions/add.js +++ b/src/pages/cipp/custom-data/schema-extensions/add.jsx @@ -1,4 +1,5 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; +import { CippIcons } from "../../../../utils/icon-registry"; import { useForm, useWatch, useFormState } from "react-hook-form"; import { ApiPostCall } from "../../../../api/ApiCall"; import { useRouter } from "next/router"; @@ -12,8 +13,6 @@ import { Divider, CardActions, } from "@mui/material"; -import { AddCircle, RemoveCircle } from "@mui/icons-material"; - import CippPageCard from "../../../../components/CippCards/CippPageCard"; import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; @@ -37,6 +36,9 @@ const Page = () => { defaultValues: { properties: [], status: "InDevelopment", + // Without a default, targetTypes.validate ran against undefined the moment the DOM-leak + // stopped swallowing the `validate` prop below. + targetTypes: [], }, }); @@ -168,7 +170,9 @@ const Page = () => { {properties && properties.map((property, index) => ( - + { onChange={(e) => handlePropertyChange(index, "type", e.target.value)} options={propertyTypeOptions} required={true} - validate={(value) => { - if (value) return true; - return "Please select a property type."; + validators={{ + validate: (value) => { + if (value) return true; + return "Please select a property type."; + }, }} /> removeProperty(index)}> - + ))} - {formControl.formState.errors.properties && ( diff --git a/src/pages/cipp/custom-data/schema-extensions/index.js b/src/pages/cipp/custom-data/schema-extensions/index.jsx similarity index 94% rename from src/pages/cipp/custom-data/schema-extensions/index.js rename to src/pages/cipp/custom-data/schema-extensions/index.jsx index 17faba908d40..93dffc91150f 100644 --- a/src/pages/cipp/custom-data/schema-extensions/index.js +++ b/src/pages/cipp/custom-data/schema-extensions/index.jsx @@ -1,10 +1,9 @@ -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; +import { CippIcons } from "../../../../utils/icon-registry" import { TabbedLayout } from "../../../../layouts/TabbedLayout"; import { CippTablePage } from "../../../../components/CippComponents/CippTablePage.jsx"; import { Alert, Button, Link, SvgIcon, Typography } from "@mui/material"; -import { Add, Block, CheckCircleOutline } from "@mui/icons-material"; import tabOptions from "../tabOptions"; -import { TrashIcon } from "@heroicons/react/24/outline"; import NextLink from "next/link"; import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; @@ -23,7 +22,7 @@ const Page = () => { const actions = [ { label: "Add Property", - icon: , + icon: , url: "/api/ExecCustomData?Action=AddSchemaProperty", type: "POST", data: { @@ -67,7 +66,7 @@ const Page = () => { }, { label: "Set to Available", - icon: , + icon: , url: "/api/ExecCustomData?Action=ChangeSchemaState", type: "POST", data: { @@ -80,7 +79,7 @@ const Page = () => { }, { label: "Set to Deprecated", - icon: , + icon: , url: "/api/ExecCustomData?Action=ChangeSchemaState", type: "POST", data: { @@ -92,7 +91,7 @@ const Page = () => { }, { label: "Delete Schema", - icon: , + icon: , url: "/api/ExecCustomData?Action=DeleteSchema", type: "POST", data: { @@ -136,7 +135,7 @@ const Page = () => { href="/cipp/custom-data/schema-extensions/add" > - + Add Schema Extensions diff --git a/src/pages/cipp/extension-sync/index.js b/src/pages/cipp/extension-sync/index.jsx similarity index 90% rename from src/pages/cipp/extension-sync/index.js rename to src/pages/cipp/extension-sync/index.jsx index 7fe6fa1c271e..b62ed014cb47 100644 --- a/src/pages/cipp/extension-sync/index.js +++ b/src/pages/cipp/extension-sync/index.jsx @@ -1,7 +1,7 @@ -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../layouts/index"; +import { CippIcons } from "../../../utils/icon-registry" import { CippTablePage } from "../../../components/CippComponents/CippTablePage.jsx"; import { Button } from "@mui/material"; -import { Refresh } from "@mui/icons-material"; const Page = () => { const pageTitle = "Extension Sync"; @@ -26,7 +26,7 @@ const Page = () => { ]} cardButton={ @@ -205,7 +207,7 @@ const Page = () => { } > - + Force Sync @@ -223,7 +225,7 @@ const Page = () => { } > - + Create Test Ticket @@ -252,7 +254,7 @@ const Page = () => { diff --git a/src/pages/cipp/integrations/index.js b/src/pages/cipp/integrations/index.jsx similarity index 84% rename from src/pages/cipp/integrations/index.js rename to src/pages/cipp/integrations/index.jsx index a60530323c46..93f05171bb54 100644 --- a/src/pages/cipp/integrations/index.js +++ b/src/pages/cipp/integrations/index.jsx @@ -1,4 +1,5 @@ -import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { Layout as DashboardLayout } from '../../../layouts/index' +import { CippIcons } from '../../../utils/icon-registry' import { Box, Button, @@ -12,7 +13,6 @@ import { Typography, } from '@mui/material' import extensions from '../../../data/Extensions' -import { Sync } from '@mui/icons-material' import { useSettings } from '../../../hooks/use-settings' import { ApiGetCall } from '../../../api/ApiCall' import Link from 'next/link' @@ -35,16 +35,17 @@ const Page = () => { + sx={{ + justifyContent: "space-between", + alignItems: "center", + mb: 3 + }}> Integrations - diff --git a/src/pages/cipp/settings/notifications.js b/src/pages/cipp/settings/notifications.jsx similarity index 99% rename from src/pages/cipp/settings/notifications.js rename to src/pages/cipp/settings/notifications.jsx index 99f2d9441bff..5021a8999f95 100644 --- a/src/pages/cipp/settings/notifications.js +++ b/src/pages/cipp/settings/notifications.jsx @@ -1,5 +1,5 @@ import { TabbedLayout } from "../../../layouts/TabbedLayout"; -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../layouts/index"; import tabOptions from "./tabOptions"; import CippFormPage from "../../../components/CippFormPages/CippFormPage"; import { useForm } from "react-hook-form"; diff --git a/src/pages/cipp/settings/partner-webhooks.js b/src/pages/cipp/settings/partner-webhooks.jsx similarity index 90% rename from src/pages/cipp/settings/partner-webhooks.js rename to src/pages/cipp/settings/partner-webhooks.jsx index e5ae63287d29..261b95ae28bf 100644 --- a/src/pages/cipp/settings/partner-webhooks.js +++ b/src/pages/cipp/settings/partner-webhooks.jsx @@ -1,5 +1,6 @@ import { TabbedLayout } from "../../../layouts/TabbedLayout"; -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { CippIcons } from "../../../utils/icon-registry"; +import { Layout as DashboardLayout } from "../../../layouts/index"; import tabOptions from "./tabOptions"; import CippFormPage from "../../../components/CippFormPages/CippFormPage"; import { useForm } from "react-hook-form"; @@ -25,7 +26,6 @@ import { CippPropertyList } from "../../../components/CippComponents/CippPropert import { CippCodeBlock } from "../../../components/CippComponents/CippCodeBlock"; import { CippTimeAgo } from "../../../components/CippComponents/CippTimeAgo"; import { useState } from "react"; -import { Close } from "@mui/icons-material"; const Page = () => { const pageTitle = "Automated Onboarding"; @@ -181,7 +181,22 @@ const Page = () => { label: "Webhook URL", value: ( - + {/* The URL is one unbreakable token and the copy button floats over the + block's top-right — wrap it and keep the text clear of the button. */} + + + {webhookUrlIsStale && ( This subscription points at a different URL than the one this instance is @@ -248,14 +263,15 @@ const Page = () => { title={ + sx={{ + justifyContent: "space-between", + alignItems: "center", + display: "flex" + }}> Test Results setTestRunning(false)}> - + diff --git a/src/pages/cipp/settings/password-config/index.js b/src/pages/cipp/settings/password-config/index.jsx similarity index 78% rename from src/pages/cipp/settings/password-config/index.js rename to src/pages/cipp/settings/password-config/index.jsx index 06d2c452a60a..e27206874aaa 100644 --- a/src/pages/cipp/settings/password-config/index.js +++ b/src/pages/cipp/settings/password-config/index.jsx @@ -1,4 +1,5 @@ import { useEffect, useState, useCallback } from "react"; +import { CippIcons } from "../../../../utils/icon-registry"; import { Alert, Box, @@ -17,10 +18,9 @@ import { Typography, } from "@mui/material"; import { Grid } from "@mui/system"; -import { Layout as DashboardLayout } from "../../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../../layouts/index"; import { useRouter } from "next/router"; import { ApiGetCall, ApiPostCall } from "../../../../api/ApiCall"; -import { ArrowLeftIcon, CheckIcon } from "@heroicons/react/24/outline"; import { CippHead } from "../../../../components/CippComponents/CippHead"; import { CippApiResults } from "../../../../components/CippComponents/CippApiResults"; @@ -54,7 +54,6 @@ function normalizeConfigForBackend(config) { }; } - const DEFAULT_CONFIG = { passwordType: PASSWORD_TYPES.CLASSIC, charCount: String(DEFAULT_VALUES.CHAR_COUNT), @@ -140,26 +139,38 @@ const Page = () => { -
    Password Configuration -
    +
    - + Type - + { color="primary" onClick={handleSave} disabled={passwordSave.isPending} - startIcon={} + startIcon={} > {passwordSave.isPending ? "Saving..." : "Save"} - + {isClassic ? "Random characters from the selected classes. Good for systems requiring specific character types. 16+ characters recommended for strong security." : "Random dictionary words joined together. Easier to remember and typically stronger at equal length. 5+ words recommended for high security."} @@ -202,13 +215,15 @@ const Page = () => { }} size="small" sx={{ width: 120 }} - inputProps={{ - style: { height: "40px" }, - min: 8, - max: 256 - }} error={config.charCount === ''} helperText={config.charCount === '' ? "Length cannot be empty" : ""} + slotProps={{ + htmlInput: { + style: { height: "40px" }, + min: 8, + max: 256 + } + }} />
    @@ -250,34 +265,49 @@ const Page = () => { ) : ( <> - - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - set("wordCount", value); - } - }} - size="small" - sx={{ width: 120, maxWidth: 160 }} - inputProps={{ - style: { height: "40px" }, - min: 2, - max: 10 - }} - error={config.wordCount === ''} - helperText={config.wordCount === '' ? "Word count cannot be empty" : ""} - /> - set("separator", e.target.value)} - size="small" - sx={{ maxWidth: 120 }} - /> - + {/* full width fields stacked on xs; helper text moves below instead of squeezing inline */} + + + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + set("wordCount", value); + } + }} + size="small" + sx={{ width: { xs: "100%", sm: 120 }, maxWidth: { xs: "100%", sm: 160 } }} + error={config.wordCount === ''} + helperText={config.wordCount === '' ? "Word count cannot be empty" : ""} + slotProps={{ + htmlInput: { + style: { height: "40px" }, + min: 2, + max: 10 + } + }} + /> + set("separator", e.target.value)} + size="small" + sx={{ width: { xs: "100%", sm: "auto" }, maxWidth: { xs: "100%", sm: 120 } }} + /> + + Allowed: single space, empty, or !@#$%^&*()-_=+/ diff --git a/src/pages/cipp/settings/permissions.js b/src/pages/cipp/settings/permissions.jsx similarity index 99% rename from src/pages/cipp/settings/permissions.js rename to src/pages/cipp/settings/permissions.jsx index 214fd478b1cb..29ecd53284e2 100644 --- a/src/pages/cipp/settings/permissions.js +++ b/src/pages/cipp/settings/permissions.jsx @@ -1,7 +1,7 @@ import { Alert, Container } from "@mui/material"; import { Grid } from "@mui/system"; import { TabbedLayout } from "../../../layouts/TabbedLayout"; -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../layouts/index"; import tabOptions from "./tabOptions"; import CippPermissionCheck from "../../../components/CippSettings/CippPermissionCheck"; import { CippPermissionReport } from "../../../components/CippSettings/CippPermissionReport"; diff --git a/src/pages/cipp/settings/siem.js b/src/pages/cipp/settings/siem.jsx similarity index 90% rename from src/pages/cipp/settings/siem.js rename to src/pages/cipp/settings/siem.jsx index 2f201199dbcf..12a281dfc40b 100644 --- a/src/pages/cipp/settings/siem.js +++ b/src/pages/cipp/settings/siem.jsx @@ -9,7 +9,7 @@ import { Typography, } from "@mui/material"; import { Grid } from "@mui/system"; -import { Layout as DashboardLayout } from "../../../layouts/index.js"; +import { Layout as DashboardLayout } from "../../../layouts/index"; import { TabbedLayout } from "../../../layouts/TabbedLayout"; import tabOptions from "./tabOptions"; import CippSiemSettings from "../../../components/CippSettings/CippSiemSettings"; @@ -44,7 +44,9 @@ const Page = () => { How Logs are Stored - + CIPP writes all log entries to an Azure Table Storage table called{" "} CippLogs. Each row is partitioned by date using the format{" "} YYYYMMDD as the PartitionKey, with a unique GUID as the{" "} @@ -67,7 +69,9 @@ const Page = () => { Available Columns - +