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.js deleted file mode 100644 index 5c33c927c1d9..000000000000 --- a/src/components/BECRemediationReportButton.js +++ /dev/null @@ -1,1317 +0,0 @@ -import { useState } from 'react' -import { - Button, - Tooltip, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Box, - Typography, - 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' -import { useBrandingSettings } from './CippPdf/useBrandingSettings' -import { - AlertBox, - Bold, - Bullet, - BulletList, - ClearBox, - ContentPage, - CoverMeta, - InfoBox, - Note, - Paragraph, - ReportDocument, - Section, - StatRow, -} from './CippPdf' - -// BEC Remediation PDF Document Component -// Exported so the branding preview can render this report against sample data, and so tests can -// render it to a real PDF. -export const BECRemediationReportDocument = ({ - userData, - becData, - brandingSettings, - tenantName, - remediationData, - variables, -}) => { - const currentDate = new Date().toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }) - - - // Helper function to format dates - const formatDate = (dateString) => { - if (!dateString) return 'N/A' - try { - return new Date(dateString).toLocaleString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - } catch { - return dateString - } - } - - const formatSafelistValue = (value) => { - if (!value) return 'unchanged' - return Array.isArray(value) ? value.join(', ') || 'unchanged' : String(value) - } - - // Calculate statistics - const stats = { - newRules: becData?.NewRules?.length || 0, - ruleChanges: becData?.InboxRuleChanges?.length || 0, - newUsers: becData?.NewUsers?.length || 0, - newApps: becData?.AddedApps?.length || 0, - permissionChanges: becData?.MailboxPermissionChanges?.length || 0, - permissionChangesTargetingUser: (becData?.MailboxPermissionChanges || []).filter( - (change) => change?.TargetsSuspect === true - ).length, - mfaDevices: becData?.MFADevices?.length || 0, - passwordChanges: becData?.ChangedPasswords?.length || 0, - sentMessages: becData?.SentMessages?.length || 0, - trustedSenders: becData?.TrustedSenders?.length || 0, - blockedSenders: becData?.BlockedSenders?.length || 0, - safelistChanges: becData?.SafelistChanges?.length || 0, - sharingChanges: becData?.SharingChanges?.length || 0, - anonymousLinks: (becData?.SharingChanges || []).filter((c) => - c?.Operation?.startsWith('AnonymousLink') - ).length, - intuneDevices: becData?.IntuneDevices?.length || 0, - signIns: becData?.SuspectUserSignIns?.length || 0, - sentTotalMessages: becData?.SentMessageAnalysis?.TotalMessages ?? 0, - sentTotalRecipients: becData?.SentMessageAnalysis?.TotalRecipients ?? 0, - repeatedSubjects: becData?.SentMessageAnalysis?.FlaggedSubjectCount || 0, - sendBursts: becData?.SentMessageAnalysis?.Bursts?.length || 0, - massMailFlagged: becData?.SentMessageAnalysis?.Flagged === true, - maliciousApps: - (becData?.AddedApps || []).filter((app) => app?.MaliciousMatch).length + - (becData?.MaliciousSPs?.length || 0), - } - - const locationAnalysis = becData?.LocationAnalysis - stats.foreignSignIns = locationAnalysis?.ForeignSignInCount || 0 - stats.foreignSuccessfulSignIns = locationAnalysis?.ForeignSuccessfulSignInCount || 0 - stats.foreignSentMessages = locationAnalysis?.ForeignSentMessageCount || 0 - stats.foreignActivity = - (locationAnalysis?.ForeignRuleChangeCount || 0) + - (locationAnalysis?.ForeignSafelistChangeCount || 0) + - (locationAnalysis?.ForeignSharingChangeCount || 0) + - (locationAnalysis?.ForeignSentMessageCount || 0) - - // the analysis window: 7 days before the data was extracted - const analysisWindowStart = (() => { - const extractedAt = becData?.ExtractedAt ? new Date(becData.ExtractedAt) : new Date() - if (Number.isNaN(extractedAt.getTime())) { - return new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) - } - return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000) - })() - - const recentIntuneDevices = (becData?.IntuneDevices || []).filter((device) => { - if (!device?.enrolledDateTime) return false - const enrolled = new Date(device.enrolledDateTime) - if (Number.isNaN(enrolled.getTime())) return false - return enrolled >= analysisWindowStart - }) - stats.recentIntuneDevices = recentIntuneDevices.length - - const isRecentMfaDevice = (method) => { - if (!method?.createdDateTime) return false - const created = new Date(method.createdDateTime) - if (Number.isNaN(created.getTime())) return false - return created >= analysisWindowStart - } - stats.recentMfaDevices = (becData?.MFADevices || []).filter(isRecentMfaDevice).length - - // successful foreign sign-ins first - they prove access, failed ones are mostly spray noise - const foreignSignIns = (becData?.SuspectUserSignIns || []) - .filter((signIn) => signIn?.ForeignLocation === true) - .sort((a, b) => (b?.Status === 'Success') - (a?.Status === 'Success')) - - const sortedIntuneDevices = [...(becData?.IntuneDevices || [])].sort((a, b) => { - const aTime = a?.enrolledDateTime ? new Date(a.enrolledDateTime).getTime() : 0 - const bTime = b?.enrolledDateTime ? new Date(b.enrolledDateTime).getTime() : 0 - return bTime - aTime - }) - - // Determine threat level - const calculateThreatLevel = () => { - let threatScore = 0 - if (stats.newRules > 0) threatScore += 3 - if (stats.ruleChanges > 0) threatScore += 3 - // A change to this mailbox's permissions outweighs unrelated tenant churn, which the - // tenant-wide search also surfaces - if (stats.permissionChangesTargetingUser > 0) threatScore += 2 - else if (stats.permissionChanges > 0) threatScore += 1 - // Generic new service principals appear constantly; the actually-bad ones score +5 below - if (stats.newApps > 0) threatScore += 1 - if (stats.newUsers > 5) threatScore += 1 - if (stats.safelistChanges > 0) threatScore += 2 - - // Check for suspicious rules (RSS folder moves) - const hasSuspiciousRules = becData?.NewRules?.some((rule) => rule.MoveToFolder?.includes('RSS')) - if (hasSuspiciousRules) threatScore += 5 - - // A catalog-matched application is a confirmed bad indicator, not a heuristic - if (stats.maliciousApps > 0) threatScore += 5 - // Only a successful foreign sign-in proves access - failed foreign attempts are - // password-spray background noise present on almost every tenant - if (stats.foreignSuccessfulSignIns > 0) threatScore += 3 - if (stats.foreignActivity > 0) threatScore += 3 - // An anonymous link exposes data to anyone holding the URL, past any later reset - if (stats.anonymousLinks > 0) threatScore += 3 - // Repeated-subject campaigns and send bursts are how a compromised mailbox spreads - if (stats.massMailFlagged) threatScore += 3 - // Persistence moves during the window: a fresh MFA method or device enrollment - if (stats.recentMfaDevices > 0) threatScore += 2 - if (stats.recentIntuneDevices > 0) threatScore += 2 - - if (threatScore >= 7) return { level: 'High', color: '#742A2A' } - if (threatScore >= 4) return { level: 'Medium', color: '#744210' } - return { level: 'Low', color: '#22543D' } - } - - const threatLevel = calculateThreatLevel() - - return ( - - } - > - {/* EXECUTIVE SUMMARY PAGE */} - - -
- - This report documents the findings of a Business Email Compromise (BEC) investigation - performed for the user account{' '} - {userData?.userPrincipalName} within{' '} - {tenantName}. The investigation analyzed - suspicious activity indicators including mailbox rules, permission changes, new - applications, authentication patterns, and sign-in locations over a 7-day period. - - - - Business Email Compromise is a sophisticated scam targeting organizations that regularly - perform wire transfers or have established relationships with foreign suppliers. - Attackers compromise legitimate email accounts through social engineering or computer - intrusion techniques to conduct unauthorized fund transfers, steal sensitive - information, or impersonate executives. - -
- -
- - - - - {threatLevel.level === 'High' && - 'HIGH RISK: Multiple indicators of compromise detected. Immediate remediation actions are strongly recommended. This account shows patterns consistent with active Business Email Compromise attacks.'} - {threatLevel.level === 'Medium' && - 'MEDIUM RISK: Suspicious activity patterns detected. Review findings and consider implementing recommended security measures. Some indicators suggest potential unauthorized access.'} - {threatLevel.level === 'Low' && - 'LOW RISK: Minimal suspicious activity detected. The findings show standard user behavior with no significant indicators of compromise. Continue monitoring as a precautionary measure.'} - -
- -
- {becData?.ExtractResult || 'Unknown'} - - Last 7 days ending {becData?.ExtractedAt ? formatDate(becData.ExtractedAt) : 'N/A'} - - - {locationAnalysis?.UsageLocation || - 'Not assigned - sign-ins and activity could not be compared against an expected country'} - -
-
- - {/* UNDERSTANDING BEC PAGE */} - - -
- - Business Email Compromise (BEC) is a type of cyberattack where criminals gain - unauthorized access to a business email account. Once inside, attackers can: - - - - Read sensitive - emails to learn about business operations, financial processes, and key - relationships. - - Send fraudulent - emails appearing to come from company leadership requesting wire transfers or - sensitive data. - - Intercept - legitimate invoices and alter payment information to redirect funds to - attacker-controlled accounts. - - Create email rules to - automatically delete or hide messages, preventing detection. - - -
- -
- - Attackers typically gain access to email accounts through: - - - - Deceptive emails that trick - users into providing their login credentials on fake websites. - - Automated attempts to - log in using common passwords across many accounts. - - Using usernames and - passwords leaked from other breached websites. - - Software that captures - keystrokes or steals stored passwords from compromised devices. - - -
- -
- - This analysis was initiated because suspicious activity was detected or reported for - this user account. The investigation examines multiple indicators that might suggest - account compromise, including unusual mailbox rules, unexpected permission changes, new - application authorizations, and abnormal sign-in patterns. Early detection is critical - to minimize potential damage and prevent financial loss or data theft. - -
-
- - {/* DETAILED FINDINGS PAGE */} - - - {/* Check 1: Mailbox Rules */} -
- - Attackers often create email rules to automatically forward, delete, or hide messages. - This prevents victims from seeing evidence of fraudulent activity. Suspicious rules - may move emails to obscure folders like "RSS Subscriptions" or forward them to - external addresses. - - - {stats.newRules > 0 && ( - <> - - The following mailbox rules were detected. Review each rule carefully to determine - if it was created by the user or by an attacker. Rules that forward emails or move - them to unusual folders are particularly suspicious. - - - {becData.NewRules.slice(0, 10).map((rule, index) => ( - - Description: {rule.Description || 'No description available'} - {'\n'} - {rule.MoveToFolder && `Moves to: ${rule.MoveToFolder}`} - {rule.ForwardTo && `\nForwards to: ${rule.ForwardTo}`} - {rule.DeleteMessage && '\nDeletes messages'} - {rule.RecentlyChanged && '\nCreated or changed in the last 7 days'} - - ))} - {becData.NewRules.length > 10 && ( - - ... and {becData.NewRules.length - 10} more rules (see JSON export for full list) - - )} - - )} - {stats.ruleChanges > 0 && ( - <> - - The audit log recorded inbox rules being created, changed or removed on this - mailbox. Rules that were removed after use are a common way for attackers to cover - their tracks. - - - {becData.InboxRuleChanges.slice(0, 10).map((change, index) => ( - - Date: {change.Date || 'Unknown'} - {'\n'} - By: {change.UserKey || 'Unknown'} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} - {change.Parameters && `\nParameters: ${change.Parameters}`} - - ))} - {becData.InboxRuleChanges.length > 10 && ( - - ... and {becData.InboxRuleChanges.length - 10} more changes (see JSON export for - full list) - - )} - - )} - {stats.newRules === 0 && stats.ruleChanges === 0 && ( - - No mailbox rules were detected that match suspicious patterns. This is a positive - indicator. - - )} -
-
- - {/* CHECK 2: NEW USERS */} - - -
- - Attackers sometimes create new user accounts to maintain persistent access or to use - as staging accounts for fraudulent activities. Reviewing recently created users helps - identify unauthorized account creation. - - - {stats.newUsers > 0 ? ( - <> - - The following users were created in the last 7 days. Verify that each account - creation was authorized and legitimate. - - - {becData.NewUsers.slice(0, 8).map((user, index) => ( - - Email: {user.userPrincipalName || 'N/A'} - {'\n'} - Created: {formatDate(user.createdDateTime)} - - ))} - {becData.NewUsers.length > 8 && ( - - ... and {becData.NewUsers.length - 8} more users (see JSON export for full list) - - )} - - ) : ( - - No new user accounts were created during the analysis period. - - )} -
- - {/* Check 3: New Applications */} -
- - Attackers may authorize malicious or suspicious third-party applications to access - your email and data. These applications can read emails, send messages, and access - files without the user's explicit knowledge. - - - {stats.maliciousApps > 0 && ( - - One or more applications in this tenant match the CIPP known-malicious application - catalog. Consent-based access survives a password reset, so these applications - should be removed unless their presence is explained. - - )} - - {stats.newApps > 0 ? ( - <> - - New applications were granted access during the analysis period. Review each - application to ensure it was authorized and is from a trusted publisher. - - - {becData.AddedApps.slice(0, 6).map((app, index) => ( - - Publisher: {app.publisher || 'Unknown'} - {'\n'} - App ID: {app.appId || 'N/A'} - {'\n'} - Created: {formatDate(app.createdDateTime)} - {app.MaliciousMatch && - `\n⚠️ Matches known-malicious catalog entry "${app.MaliciousMatch.Name}"${ - app.MaliciousMatch.Categories?.length - ? ` (${app.MaliciousMatch.Categories.join(', ')})` - : '' - }`} - - ))} - {becData.AddedApps.length > 6 && ( - - ... and {becData.AddedApps.length - 6} more apps (see JSON export for full list) - - )} - - ) : ( - (becData?.MaliciousSPs?.length || 0) === 0 && ( - - No new applications were authorized during the analysis period, and no known - malicious applications are present in the tenant. - - ) - )} - - {(becData?.MaliciousSPs?.length || 0) > 0 && ( - <> - {becData.MaliciousSPs.slice(0, 6).map((app, index) => ( - - Catalog entry: {app.CatalogName || 'Unknown'} - {'\n'} - App ID: {app.appId || 'N/A'} - {'\n'} - Categories: {app.Categories?.length ? app.Categories.join(', ') : 'N/A'} - {'\n'} - Enabled: {String(app.accountEnabled ?? 'Unknown')} - {'\n'} - First seen: {formatDate(app.createdDateTime)} - - ))} - {becData.MaliciousSPs.length > 6 && ( - - ... and {becData.MaliciousSPs.length - 6} more (see JSON export for full list) - - )} - - )} -
-
- - {/* CHECK 4, 5, 6, 7: PERMISSIONS, SENT MAIL, MFA, PASSWORDS */} - - - {/* Check 4: Mailbox Permission Changes */} -
- - Unauthorized changes to mailbox permissions can allow attackers to grant themselves or - accomplices access to read, send, or manage emails. This is a common technique to - maintain persistent access. - - - {stats.permissionChanges > 0 ? ( - <> - - Mailbox permission changes were detected. Verify that each change was authorized - and necessary for legitimate business purposes. - - - {becData.MailboxPermissionChanges.slice(0, 5).map((change, index) => ( - - User: {change.UserKey || 'Unknown'} - {'\n'} - Target: {change.ObjectId || 'N/A'} - {'\n'} - Permissions: {change.Permissions || 'Unknown'} - {change.TargetsSuspect === true && - '\n⚠️ Targets the investigated mailbox'} - - ))} - {becData.MailboxPermissionChanges.length > 5 && ( - - ... and {becData.MailboxPermissionChanges.length - 5} more changes - - )} - - ) : ( - - No mailbox permission changes were detected during the analysis period. - - )} -
- - {/* Check 5: Sent Messages */} -
- - Attackers use a compromised mailbox to send fraudulent invoices, phishing, or - internal impersonation mail. The message trace shows what actually left the mailbox - during the analysis period, including the IP address it was sent from. - - - {stats.sentMessages > 0 ? ( - <> - - ℹ️ {stats.sentTotalMessages || stats.sentMessages} message(s) to{' '} - {stats.sentTotalRecipients || stats.sentMessages} recipient(s) were sent by this - mailbox during the analysis period - {stats.foreignSentMessages > 0 - ? `, including ${stats.foreignSentMessages} from an IP outside the user's assigned usage location.` - : '.'} - - - {stats.massMailFlagged && ( - - {stats.repeatedSubjects > 0 - ? `${stats.repeatedSubjects} subject(s) were sent as many separate messages or to many recipients. ` - : ''} - {stats.sendBursts > 0 - ? `${stats.sendBursts} short burst(s) of high-volume sending were detected. ` - : ''} - Identical-subject mass mail and send bursts are how a compromised mailbox - spreads phishing or fraudulent invoices. Review the campaigns below and warn - the recipients if the content was malicious. - - )} - - {(becData?.SentMessageAnalysis?.RepeatedSubjects || []) - .slice(0, 5) - .map((group, index) => ( - - Messages: {group.MessageCount} - {'\n'} - Recipients: {group.RecipientCount} - {'\n'} - First sent: {group.FirstSent || 'N/A'} - {'\n'} - Last sent: {group.LastSent || 'N/A'} - - ))} - {(becData?.SentMessageAnalysis?.RepeatedSubjects?.length || 0) > 5 && ( - - ... and {becData.SentMessageAnalysis.RepeatedSubjects.length - 5} more repeated - subjects (see JSON export for full list) - - )} - - {(becData?.SentMessageAnalysis?.Bursts || []).slice(0, 5).map((burst, index) => ( - - Starting: {burst.WindowStart || 'N/A'} - {burst.TopSubject && `\nMost common subject: ${burst.TopSubject}`} - - ))} - {(becData?.SentMessageAnalysis?.Bursts?.length || 0) > 5 && ( - - ... and {becData.SentMessageAnalysis.Bursts.length - 5} more bursts (see JSON - export for full list) - - )} - - {becData.SentMessages.slice(0, 10).map((msg, index) => ( - - To: {msg.RecipientAddress || 'N/A'} - {'\n'} - Status: {msg.Status || 'N/A'} - {'\n'} - Received: {msg.Received || 'N/A'} - {msg.FromIP && - `\nFrom IP: ${msg.FromIP}${msg.Country ? ` (${msg.Country})` : ''}`} - {msg.ForeignLocation === true && - '\n⚠️ Sent from outside the assigned usage location'} - - ))} - {becData.SentMessages.length > 10 && ( - - ... and {becData.SentMessages.length - 10} more messages (see JSON export for - full list) - - )} - - ) : ( - - No messages were sent by this mailbox during the analysis period. - - )} -
- - {/* Check 6: MFA Devices */} -
- - Multi-factor authentication (MFA) devices provide an additional layer of security. - Reviewing registered MFA methods helps identify if attackers have added unauthorized - devices to bypass security controls. - - - {stats.mfaDevices > 0 ? ( - <> - - ℹ️ {stats.mfaDevices} MFA device(s) registered - {stats.recentMfaDevices > 0 - ? `, including ${stats.recentMfaDevices} registered in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.` - : '. Verify each device belongs to the user.'} - - - {[...becData.MFADevices] - .sort( - (a, b) => new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0) - ) - .slice(0, 5) - .map((device, index) => ( - - Display Name: {device.displayName || 'N/A'} - {'\n'} - Registered: {formatDate(device.createdDateTime)} - {isRecentMfaDevice(device) && '\n⚠️ Registered in the last 7 days'} - - ))} - {becData.MFADevices.length > 5 && ( - - ... and {becData.MFADevices.length - 5} more methods (see JSON export for full - list) - - )} - - ) : ( - - No multi-factor authentication devices are registered. MFA is highly recommended to - prevent unauthorized access. - - )} -
- - {/* Check 7: Password Changes */} -
- - Attackers often change passwords to lock out legitimate users. Reviewing recent - password changes in the tenant helps identify if the compromised account's password - was changed or if other accounts were affected. - - - {stats.passwordChanges > 0 ? ( - <> - - ℹ️ {stats.passwordChanges} password change(s) detected in the tenant during the - analysis period. - - - {becData.ChangedPasswords.slice(0, 5).map((user, index) => ( - - Email: {user.userPrincipalName || 'N/A'} - {'\n'} - Last Password Change: {formatDate(user.lastPasswordChangeDateTime)} - - ))} - {becData.ChangedPasswords.length > 5 && ( - - ... and {becData.ChangedPasswords.length - 5} more (see JSON export for full - list) - - )} - - ) : ( - - ℹ️ No password changes detected during the analysis period. - - )} -
-
- - {/* CHECK 8, 9, 10: SENDER LISTS, DEVICES, LOCATIONS */} - - - {/* Check 8: Trusted & Blocked Senders */} -
- - Attackers may add their own domain to the Trusted Senders list so their fraudulent - messages bypass spam filtering, or add finance/security domains to the Blocked - Senders list so warnings and alerts are hidden from the victim in the Junk Email - folder. - - - {becData?.SafelistError && ( - - {becData.SafelistError} - {'\n'} - An empty list here does not mean the mailbox has no trusted or blocked senders. - - )} - - {stats.safelistChanges > 0 && ( - <> - - The audit log recorded changes to the Trusted/Blocked Senders and Domains list on - this mailbox. Review each change carefully. - - - {becData.SafelistChanges.slice(0, 10).map((change, index) => ( - - Date: {formatDate(change.Date)} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} - {'\n'} - Trusted: {formatSafelistValue(change.Trusted)} - {'\n'} - Blocked: {formatSafelistValue(change.Blocked)} - - ))} - {becData.SafelistChanges.length > 10 && ( - - ... and {becData.SafelistChanges.length - 10} more changes (see JSON export for - full list) - - )} - - )} - - {stats.trustedSenders > 0 && ( - {becData.TrustedSenders.slice(0, 15).join(', ')} - )} - {stats.trustedSenders > 15 && ( - - ... and {stats.trustedSenders - 15} more trusted entries (see JSON export for full - list) - - )} - - {stats.blockedSenders > 0 && ( - {becData.BlockedSenders.slice(0, 15).join(', ')} - )} - {stats.blockedSenders > 15 && ( - - ... and {stats.blockedSenders - 15} more blocked entries (see JSON export for full - list) - - )} - - {!becData?.SafelistError && - stats.trustedSenders === 0 && - stats.blockedSenders === 0 && - stats.safelistChanges === 0 && ( - - No trusted or blocked sender/domain entries were found on this mailbox. - - )} -
- - {/* Check 9: Intune Devices */} -
- - Newly enrolled Intune devices can indicate an attacker standing up a VM or BYOD - endpoint under the compromised identity, including paths that re-register Windows - Hello for Business. Review devices enrolled during the analysis window first. - - - {becData?.IntuneDevicesError ? ( - - {becData.IntuneDevicesError} - {'\n'} - An empty device list here does not mean the user has no Intune devices. - - ) : stats.intuneDevices > 0 ? ( - <> - - ℹ️ {stats.intuneDevices} Intune-managed device(s) associated with this user - {stats.recentIntuneDevices > 0 - ? `, including ${stats.recentIntuneDevices} enrolled in the last 7 days.` - : '. None were enrolled in the last 7 days.'} - - - {sortedIntuneDevices.slice(0, 5).map((device, index) => ( - - OS: {device.operatingSystem || 'N/A'} - {device.osVersion ? ` ${device.osVersion}` : ''} - {'\n'} - Enrolled: {formatDate(device.enrolledDateTime)} - {'\n'} - Compliance: {device.complianceState || 'N/A'} - {'\n'} - Enrollment Type: {device.deviceEnrollmentType || 'N/A'} - {device.serialNumber ? `\nSerial: ${device.serialNumber}` : ''} - - ))} - {sortedIntuneDevices.length > 5 && ( - - ... and {sortedIntuneDevices.length - 5} more devices (see JSON export for full - list) - - )} - - ) : ( - - No Intune-managed devices were found for this user. - - )} -
- - {/* Check 10: Sign-in Locations */} -
- - Sign-ins from countries the user does not work from are one of the strongest - compromise indicators. Each sign-in is compared against the user's assigned usage - location in Entra ID - {locationAnalysis?.UsageLocation ? ` (${locationAnalysis.UsageLocation})` : ''}, and - the client IPs behind rule changes, safelist changes, sharing changes, and sent mail - are geo-located and compared the same way. - - - {becData?.SuspectUserSignInsError ? ( - - {becData.SuspectUserSignInsError} - {'\n'} - An empty list here does not mean the user has not signed in. - - ) : ( - <> - {!locationAnalysis?.UsageLocation && ( - - {locationAnalysis?.Note || - 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'} - - )} - - {(locationAnalysis?.SignInCountries?.length || 0) > 0 && ( - - {locationAnalysis.SignInCountries.map( - (c) => `${c.Country}: ${c.Count} sign-in(s)` - ).join('\n')} - - )} - - {stats.foreignSignIns > 0 || stats.foreignActivity > 0 ? ( - <> - - {stats.foreignSignIns} sign-in(s) (of which {stats.foreignSuccessfulSignIns}{' '} - succeeded), {locationAnalysis?.ForeignRuleChangeCount || 0} inbox rule - change(s), {locationAnalysis?.ForeignSafelistChangeCount || 0} safelist - change(s), {locationAnalysis?.ForeignSharingChangeCount || 0} sharing - change(s), and {locationAnalysis?.ForeignSentMessageCount || 0} sent - message(s) originated outside {locationAnalysis?.UsageLocation}. Failed - foreign sign-ins are mostly password-spray noise; the successful ones prove - access. Review each carefully — a single legitimate trip can explain some of - this, but rule, safelist, or sharing changes from a foreign IP rarely have an - innocent explanation. - - - {foreignSignIns.slice(0, 10).map((signIn, index) => ( - - Application: {signIn.AppDisplayName || 'N/A'} - {'\n'} - IP Address: {signIn.IPAddress || 'N/A'} - {'\n'} - City: {signIn.City || 'N/A'} - {'\n'} - Result: {signIn.Status || 'N/A'} - - ))} - {foreignSignIns.length > 10 && ( - - ... and {foreignSignIns.length - 10} more foreign sign-ins (see JSON export - for full list) - - )} - - ) : locationAnalysis?.UsageLocation ? ( - - All located sign-ins and activity match the user's assigned usage location ( - {locationAnalysis.UsageLocation}). - - ) : null} - - )} -
- - {/* Check 11: Sharing Links */} -
- - Attackers share OneDrive and SharePoint folders to give themselves a data feed that - survives a password reset, and anonymous links expose the content to anyone holding - the URL. This check lists every sharing link the account created or changed during - the analysis period, including the IP address it was done from. - - - {stats.sharingChanges > 0 ? ( - <> - - {stats.anonymousLinks > 0 - ? `${stats.anonymousLinks} of these involve anonymous links, which anyone with the URL can open. ` - : ''} - Review each link and remove any that are not explained, even if the account has - since been remediated. - - - {becData.SharingChanges.slice(0, 10).map((change, index) => ( - - Date: {formatDate(change.Date)} - {'\n'} - Workload: {change.Workload || 'N/A'} - {change.Target && `\nShared with: ${change.Target}`} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} - - ))} - {becData.SharingChanges.length > 10 && ( - - ... and {becData.SharingChanges.length - 10} more changes (see JSON export for - full list) - - )} - - ) : ( - - No sharing links were created or changed by this account during the analysis - period. - - )} -
-
- - {/* RECOMMENDATIONS PAGE */} - - -
- - Based on the investigation findings, the following actions should be taken immediately: - - - - Change the user's - password immediately to prevent further unauthorized access. - - Sign out the user from - all active sessions to terminate any attacker access. - - Delete any - mailbox rules that forward, redirect, or hide emails, especially those moving - messages to unusual folders. - - Remove any MFA - devices that the user doesn't recognize and re-register legitimate devices. - - Review and revoke any - unauthorized mailbox permissions or application consents. - - Continue monitoring the - account for suspicious activity for at least 30 days. - - -
- -
- - To prevent future Business Email Compromise attacks, implement these security best - practices: - - - - {' '} - Require MFA for all users, especially those with administrative privileges or access - to financial systems. - - {' '} - Educate employees about phishing, social engineering, and how to identify suspicious - emails. Regular training significantly reduces successful attacks. - - Use - email security solutions that detect and block phishing, malware, and suspicious - attachments. - - {' '} - Restrict access based on location, device compliance, and risk level to prevent - unauthorized sign-ins. - - Regularly review - audit logs for suspicious activities such as unusual sign-in patterns, rule - creation, or permission changes. - - Implement - multi-person approval processes for wire transfers and payment changes to prevent - fraudulent transactions. - - -
- -
- - Share these key points with the affected user to help prevent future compromises: - - - - - Never click on links or open attachments in unexpected emails, even if they appear - to come from known contacts. - - - Always verify unusual requests for money transfers or sensitive information through - a separate communication channel (phone call, in person). - - - Use strong, unique passwords for each account and consider using a password manager. - - - Be cautious when authorizing new applications or granting permissions to third-party - services. - - - Report suspicious emails or activities to your IT security team immediately. - - -
-
- - {/* COMPLIANCE & DOCUMENTATION PAGE */} - - -
- - This report supports compliance and documentation requirements for various security - frameworks and regulatory standards: - - - - Demonstrates incident - detection, analysis, and response procedures (Controls A.16.1.1 - A.16.1.7). - - Provides evidence of - security incident monitoring, analysis, and documentation (AC.L2-3.1.12, - AU.L2-3.3.1). - - Documents detective and - responsive controls for security incidents (CC7.3, CC7.4). - - Aligns with Detect (DE.AE, - DE.CM) and Respond (RS.AN, RS.MI) functions. - - Demonstrates security breach - detection and potential data breach assessment (Articles 32, 33). - - -
- -
- - This investigation and resulting documentation provide an audit trail for security - incident response: - - - - Investigation Date: {formatDate(becData?.ExtractedAt)} - {'\n'} - Analyzed User: {userData?.userPrincipalName} - {'\n'} - Organization: {tenantName} - {'\n'} - Analysis Period: 7 days - {'\n'} - Assigned Usage Location: {locationAnalysis?.UsageLocation || 'Not assigned'} - {'\n'} - Audit Log Status: {becData?.ExtractResult || 'Unknown'} - - - - Threat Level: {threatLevel.level} - {'\n'} - Mailbox Rules Found: {stats.newRules} - {'\n'} - Rule Changes: {stats.ruleChanges} - {'\n'} - Permission Changes: {stats.permissionChanges} ({stats.permissionChangesTargetingUser}{' '} - targeting this mailbox) - {'\n'} - New Applications: {stats.newApps} - {'\n'} - Known-Malicious Applications: {stats.maliciousApps} - {'\n'} - New Users: {stats.newUsers} - {'\n'} - Sent Messages: {stats.sentTotalMessages || stats.sentMessages} - {'\n'} - Repeated Subject Campaigns: {stats.repeatedSubjects} - {'\n'} - Send Bursts: {stats.sendBursts} - {'\n'} - MFA Devices: {stats.mfaDevices} - {'\n'} - Recent MFA Registrations (7d): {stats.recentMfaDevices} - {'\n'} - Password Changes: {stats.passwordChanges} - {'\n'} - Trusted Senders: {stats.trustedSenders} - {'\n'} - Blocked Senders: {stats.blockedSenders} - {'\n'} - Safelist Changes: {stats.safelistChanges} - {'\n'} - Sharing Changes: {stats.sharingChanges} - {'\n'} - Anonymous Links: {stats.anonymousLinks} - {'\n'} - Intune Devices: {stats.intuneDevices} - {'\n'} - Recent Intune Enrollments (7d): {stats.recentIntuneDevices} - {'\n'} - Foreign Sign-ins: {stats.foreignSignIns} ({stats.foreignSuccessfulSignIns} successful) - {'\n'} - Foreign Rule/Safelist/Sharing/Mail Activity: {stats.foreignActivity} - -
- -
- - This report should be retained according to your organization's document retention - policy and regulatory requirements. Typical retention periods range from 3-7 years - depending on applicable compliance frameworks. Store this document securely with - restricted access as it contains sensitive security information. - -
- -
- - For more information about Business Email Compromise and cybersecurity best practices: - - - - - FBI IC3: Internet Crime Complaint Center (ic3.gov) - - - CISA: Cybersecurity & Infrastructure Security Agency (cisa.gov) - - - Microsoft Security: Business Email Compromise resources - - -
-
-
- ) -} - -// Main Button Component -export const BECRemediationReportButton = ({ userData, becData, tenantName }) => { - const [dialogOpen, setDialogOpen] = useState(false) - const [isGenerating, setIsGenerating] = useState(false) - - // Check if we have the necessary data - const hasData = userData && becData && !becData.Waiting - - const brandingSettings = useBrandingSettings() - const variables = useReportVariables() - - const handleOpenDialog = () => { - setDialogOpen(true) - } - - const handleCloseDialog = () => { - setDialogOpen(false) - } - - if (!hasData) { - return null // Don't show button if data isn't ready - } - - return ( - <> - - - - - - - - - BEC Remediation Report Preview - - - - - - - - {hasData && ( - - - - )} - - - - - } - fileName={`BEC_Report_${userData?.userPrincipalName}_${new Date().toISOString().split('T')[0]}.pdf`} - style={{ textDecoration: 'none' }} - > - {({ loading }) => ( - - )} - - - - - ) -} \ No newline at end of file diff --git a/src/components/BECRemediationReportButton.jsx b/src/components/BECRemediationReportButton.jsx new file mode 100644 index 000000000000..7cb7b06a2c9e --- /dev/null +++ b/src/components/BECRemediationReportButton.jsx @@ -0,0 +1,1323 @@ +import { useState } from 'react' +import { CippIcons } from '../utils/icon-registry' +import { + Button, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Box, + Typography, + IconButton, + CircularProgress, +} from '@mui/material' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' +import { useReportVariables } from './CippPdf/useReportVariables' +import { useBrandingSettings } from './CippPdf/useBrandingSettings' +import { + AlertBox, + Bold, + Bullet, + BulletList, + ClearBox, + ContentPage, + CoverMeta, + InfoBox, + Note, + Paragraph, + ReportDocument, + Section, + StatRow, +} from './CippPdf' + +// BEC Remediation PDF Document Component +// Exported so the branding preview can render this report against sample data, and so tests can +// render it to a real PDF. +export const BECRemediationReportDocument = ({ + userData, + becData, + brandingSettings, + tenantName, + remediationData, + variables, +}) => { + const currentDate = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + + // Helper function to format dates + const formatDate = (dateString) => { + if (!dateString) return 'N/A' + try { + return new Date(dateString).toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return dateString + } + } + + const formatSafelistValue = (value) => { + if (!value) return 'unchanged' + return Array.isArray(value) ? value.join(', ') || 'unchanged' : String(value) + } + + // Calculate statistics + const stats = { + newRules: becData?.NewRules?.length || 0, + ruleChanges: becData?.InboxRuleChanges?.length || 0, + newUsers: becData?.NewUsers?.length || 0, + newApps: becData?.AddedApps?.length || 0, + permissionChanges: becData?.MailboxPermissionChanges?.length || 0, + permissionChangesTargetingUser: (becData?.MailboxPermissionChanges || []).filter( + (change) => change?.TargetsSuspect === true + ).length, + mfaDevices: becData?.MFADevices?.length || 0, + passwordChanges: becData?.ChangedPasswords?.length || 0, + sentMessages: becData?.SentMessages?.length || 0, + trustedSenders: becData?.TrustedSenders?.length || 0, + blockedSenders: becData?.BlockedSenders?.length || 0, + safelistChanges: becData?.SafelistChanges?.length || 0, + sharingChanges: becData?.SharingChanges?.length || 0, + anonymousLinks: (becData?.SharingChanges || []).filter((c) => + c?.Operation?.startsWith('AnonymousLink') + ).length, + intuneDevices: becData?.IntuneDevices?.length || 0, + signIns: becData?.SuspectUserSignIns?.length || 0, + sentTotalMessages: becData?.SentMessageAnalysis?.TotalMessages ?? 0, + sentTotalRecipients: becData?.SentMessageAnalysis?.TotalRecipients ?? 0, + repeatedSubjects: becData?.SentMessageAnalysis?.FlaggedSubjectCount || 0, + sendBursts: becData?.SentMessageAnalysis?.Bursts?.length || 0, + massMailFlagged: becData?.SentMessageAnalysis?.Flagged === true, + maliciousApps: + (becData?.AddedApps || []).filter((app) => app?.MaliciousMatch).length + + (becData?.MaliciousSPs?.length || 0), + } + + const locationAnalysis = becData?.LocationAnalysis + stats.foreignSignIns = locationAnalysis?.ForeignSignInCount || 0 + stats.foreignSuccessfulSignIns = locationAnalysis?.ForeignSuccessfulSignInCount || 0 + stats.foreignSentMessages = locationAnalysis?.ForeignSentMessageCount || 0 + stats.foreignActivity = + (locationAnalysis?.ForeignRuleChangeCount || 0) + + (locationAnalysis?.ForeignSafelistChangeCount || 0) + + (locationAnalysis?.ForeignSharingChangeCount || 0) + + (locationAnalysis?.ForeignSentMessageCount || 0) + + // the analysis window: 7 days before the data was extracted + const analysisWindowStart = (() => { + const extractedAt = becData?.ExtractedAt ? new Date(becData.ExtractedAt) : new Date() + if (Number.isNaN(extractedAt.getTime())) { + return new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) + } + return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000) + })() + + const recentIntuneDevices = (becData?.IntuneDevices || []).filter((device) => { + if (!device?.enrolledDateTime) return false + const enrolled = new Date(device.enrolledDateTime) + if (Number.isNaN(enrolled.getTime())) return false + return enrolled >= analysisWindowStart + }) + stats.recentIntuneDevices = recentIntuneDevices.length + + const isRecentMfaDevice = (method) => { + if (!method?.createdDateTime) return false + const created = new Date(method.createdDateTime) + if (Number.isNaN(created.getTime())) return false + return created >= analysisWindowStart + } + stats.recentMfaDevices = (becData?.MFADevices || []).filter(isRecentMfaDevice).length + + // successful foreign sign-ins first - they prove access, failed ones are mostly spray noise + const foreignSignIns = (becData?.SuspectUserSignIns || []) + .filter((signIn) => signIn?.ForeignLocation === true) + .sort((a, b) => (b?.Status === 'Success') - (a?.Status === 'Success')) + + const sortedIntuneDevices = [...(becData?.IntuneDevices || [])].sort((a, b) => { + const aTime = a?.enrolledDateTime ? new Date(a.enrolledDateTime).getTime() : 0 + const bTime = b?.enrolledDateTime ? new Date(b.enrolledDateTime).getTime() : 0 + return bTime - aTime + }) + + // Determine threat level + const calculateThreatLevel = () => { + let threatScore = 0 + if (stats.newRules > 0) threatScore += 3 + if (stats.ruleChanges > 0) threatScore += 3 + // A change to this mailbox's permissions outweighs unrelated tenant churn, which the + // tenant-wide search also surfaces + if (stats.permissionChangesTargetingUser > 0) threatScore += 2 + else if (stats.permissionChanges > 0) threatScore += 1 + // Generic new service principals appear constantly; the actually-bad ones score +5 below + if (stats.newApps > 0) threatScore += 1 + if (stats.newUsers > 5) threatScore += 1 + if (stats.safelistChanges > 0) threatScore += 2 + + // Check for suspicious rules (RSS folder moves) + const hasSuspiciousRules = becData?.NewRules?.some((rule) => rule.MoveToFolder?.includes('RSS')) + if (hasSuspiciousRules) threatScore += 5 + + // A catalog-matched application is a confirmed bad indicator, not a heuristic + if (stats.maliciousApps > 0) threatScore += 5 + // Only a successful foreign sign-in proves access - failed foreign attempts are + // password-spray background noise present on almost every tenant + if (stats.foreignSuccessfulSignIns > 0) threatScore += 3 + if (stats.foreignActivity > 0) threatScore += 3 + // An anonymous link exposes data to anyone holding the URL, past any later reset + if (stats.anonymousLinks > 0) threatScore += 3 + // Repeated-subject campaigns and send bursts are how a compromised mailbox spreads + if (stats.massMailFlagged) threatScore += 3 + // Persistence moves during the window: a fresh MFA method or device enrollment + if (stats.recentMfaDevices > 0) threatScore += 2 + if (stats.recentIntuneDevices > 0) threatScore += 2 + + if (threatScore >= 7) return { level: 'High', color: '#742A2A' } + if (threatScore >= 4) return { level: 'Medium', color: '#744210' } + return { level: 'Low', color: '#22543D' } + } + + const threatLevel = calculateThreatLevel() + + return ( + + } + > + {/* EXECUTIVE SUMMARY PAGE */} + + +
+ + This report documents the findings of a Business Email Compromise (BEC) investigation + performed for the user account{' '} + {userData?.userPrincipalName} within{' '} + {tenantName}. The investigation analyzed + suspicious activity indicators including mailbox rules, permission changes, new + applications, authentication patterns, and sign-in locations over a 7-day period. + + + + Business Email Compromise is a sophisticated scam targeting organizations that regularly + perform wire transfers or have established relationships with foreign suppliers. + Attackers compromise legitimate email accounts through social engineering or computer + intrusion techniques to conduct unauthorized fund transfers, steal sensitive + information, or impersonate executives. + +
+ +
+ + + + + {threatLevel.level === 'High' && + 'HIGH RISK: Multiple indicators of compromise detected. Immediate remediation actions are strongly recommended. This account shows patterns consistent with active Business Email Compromise attacks.'} + {threatLevel.level === 'Medium' && + 'MEDIUM RISK: Suspicious activity patterns detected. Review findings and consider implementing recommended security measures. Some indicators suggest potential unauthorized access.'} + {threatLevel.level === 'Low' && + 'LOW RISK: Minimal suspicious activity detected. The findings show standard user behavior with no significant indicators of compromise. Continue monitoring as a precautionary measure.'} + +
+ +
+ {becData?.ExtractResult || 'Unknown'} + + Last 7 days ending {becData?.ExtractedAt ? formatDate(becData.ExtractedAt) : 'N/A'} + + + {locationAnalysis?.UsageLocation || + 'Not assigned - sign-ins and activity could not be compared against an expected country'} + +
+
+ + {/* UNDERSTANDING BEC PAGE */} + + +
+ + Business Email Compromise (BEC) is a type of cyberattack where criminals gain + unauthorized access to a business email account. Once inside, attackers can: + + + + Read sensitive + emails to learn about business operations, financial processes, and key + relationships. + + Send fraudulent + emails appearing to come from company leadership requesting wire transfers or + sensitive data. + + Intercept + legitimate invoices and alter payment information to redirect funds to + attacker-controlled accounts. + + Create email rules to + automatically delete or hide messages, preventing detection. + + +
+ +
+ + Attackers typically gain access to email accounts through: + + + + Deceptive emails that trick + users into providing their login credentials on fake websites. + + Automated attempts to + log in using common passwords across many accounts. + + Using usernames and + passwords leaked from other breached websites. + + Software that captures + keystrokes or steals stored passwords from compromised devices. + + +
+ +
+ + This analysis was initiated because suspicious activity was detected or reported for + this user account. The investigation examines multiple indicators that might suggest + account compromise, including unusual mailbox rules, unexpected permission changes, new + application authorizations, and abnormal sign-in patterns. Early detection is critical + to minimize potential damage and prevent financial loss or data theft. + +
+
+ + {/* DETAILED FINDINGS PAGE */} + + + {/* Check 1: Mailbox Rules */} +
+ + Attackers often create email rules to automatically forward, delete, or hide messages. + This prevents victims from seeing evidence of fraudulent activity. Suspicious rules + may move emails to obscure folders like "RSS Subscriptions" or forward them to + external addresses. + + + {stats.newRules > 0 && ( + <> + + The following mailbox rules were detected. Review each rule carefully to determine + if it was created by the user or by an attacker. Rules that forward emails or move + them to unusual folders are particularly suspicious. + + + {becData.NewRules.slice(0, 10).map((rule, index) => ( + + Description: {rule.Description || 'No description available'} + {'\n'} + {rule.MoveToFolder && `Moves to: ${rule.MoveToFolder}`} + {rule.ForwardTo && `\nForwards to: ${rule.ForwardTo}`} + {rule.DeleteMessage && '\nDeletes messages'} + {rule.RecentlyChanged && '\nCreated or changed in the last 7 days'} + + ))} + {becData.NewRules.length > 10 && ( + + ... and {becData.NewRules.length - 10} more rules (see JSON export for full list) + + )} + + )} + {stats.ruleChanges > 0 && ( + <> + + The audit log recorded inbox rules being created, changed or removed on this + mailbox. Rules that were removed after use are a common way for attackers to cover + their tracks. + + + {becData.InboxRuleChanges.slice(0, 10).map((change, index) => ( + + Date: {change.Date || 'Unknown'} + {'\n'} + By: {change.UserKey || 'Unknown'} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} + {change.Parameters && `\nParameters: ${change.Parameters}`} + + ))} + {becData.InboxRuleChanges.length > 10 && ( + + ... and {becData.InboxRuleChanges.length - 10} more changes (see JSON export for + full list) + + )} + + )} + {stats.newRules === 0 && stats.ruleChanges === 0 && ( + + No mailbox rules were detected that match suspicious patterns. This is a positive + indicator. + + )} +
+
+ + {/* CHECK 2: NEW USERS */} + + +
+ + Attackers sometimes create new user accounts to maintain persistent access or to use + as staging accounts for fraudulent activities. Reviewing recently created users helps + identify unauthorized account creation. + + + {stats.newUsers > 0 ? ( + <> + + The following users were created in the last 7 days. Verify that each account + creation was authorized and legitimate. + + + {becData.NewUsers.slice(0, 8).map((user, index) => ( + + Email: {user.userPrincipalName || 'N/A'} + {'\n'} + Created: {formatDate(user.createdDateTime)} + + ))} + {becData.NewUsers.length > 8 && ( + + ... and {becData.NewUsers.length - 8} more users (see JSON export for full list) + + )} + + ) : ( + + No new user accounts were created during the analysis period. + + )} +
+ + {/* Check 3: New Applications */} +
+ + Attackers may authorize malicious or suspicious third-party applications to access + your email and data. These applications can read emails, send messages, and access + files without the user's explicit knowledge. + + + {stats.maliciousApps > 0 && ( + + One or more applications in this tenant match the CIPP known-malicious application + catalog. Consent-based access survives a password reset, so these applications + should be removed unless their presence is explained. + + )} + + {stats.newApps > 0 ? ( + <> + + New applications were granted access during the analysis period. Review each + application to ensure it was authorized and is from a trusted publisher. + + + {becData.AddedApps.slice(0, 6).map((app, index) => ( + + Publisher: {app.publisher || 'Unknown'} + {'\n'} + App ID: {app.appId || 'N/A'} + {'\n'} + Created: {formatDate(app.createdDateTime)} + {app.MaliciousMatch && + `\n⚠️ Matches known-malicious catalog entry "${app.MaliciousMatch.Name}"${ + app.MaliciousMatch.Categories?.length + ? ` (${app.MaliciousMatch.Categories.join(', ')})` + : '' + }`} + + ))} + {becData.AddedApps.length > 6 && ( + + ... and {becData.AddedApps.length - 6} more apps (see JSON export for full list) + + )} + + ) : ( + (becData?.MaliciousSPs?.length || 0) === 0 && ( + + No new applications were authorized during the analysis period, and no known + malicious applications are present in the tenant. + + ) + )} + + {(becData?.MaliciousSPs?.length || 0) > 0 && ( + <> + {becData.MaliciousSPs.slice(0, 6).map((app, index) => ( + + Catalog entry: {app.CatalogName || 'Unknown'} + {'\n'} + App ID: {app.appId || 'N/A'} + {'\n'} + Categories: {app.Categories?.length ? app.Categories.join(', ') : 'N/A'} + {'\n'} + Enabled: {String(app.accountEnabled ?? 'Unknown')} + {'\n'} + First seen: {formatDate(app.createdDateTime)} + + ))} + {becData.MaliciousSPs.length > 6 && ( + + ... and {becData.MaliciousSPs.length - 6} more (see JSON export for full list) + + )} + + )} +
+
+ + {/* CHECK 4, 5, 6, 7: PERMISSIONS, SENT MAIL, MFA, PASSWORDS */} + + + {/* Check 4: Mailbox Permission Changes */} +
+ + Unauthorized changes to mailbox permissions can allow attackers to grant themselves or + accomplices access to read, send, or manage emails. This is a common technique to + maintain persistent access. + + + {stats.permissionChanges > 0 ? ( + <> + + Mailbox permission changes were detected. Verify that each change was authorized + and necessary for legitimate business purposes. + + + {becData.MailboxPermissionChanges.slice(0, 5).map((change, index) => ( + + User: {change.UserKey || 'Unknown'} + {'\n'} + Target: {change.ObjectId || 'N/A'} + {'\n'} + Permissions: {change.Permissions || 'Unknown'} + {change.TargetsSuspect === true && + '\n⚠️ Targets the investigated mailbox'} + + ))} + {becData.MailboxPermissionChanges.length > 5 && ( + + ... and {becData.MailboxPermissionChanges.length - 5} more changes + + )} + + ) : ( + + No mailbox permission changes were detected during the analysis period. + + )} +
+ + {/* Check 5: Sent Messages */} +
+ + Attackers use a compromised mailbox to send fraudulent invoices, phishing, or + internal impersonation mail. The message trace shows what actually left the mailbox + during the analysis period, including the IP address it was sent from. + + + {stats.sentMessages > 0 ? ( + <> + + ℹ️ {stats.sentTotalMessages || stats.sentMessages} message(s) to{' '} + {stats.sentTotalRecipients || stats.sentMessages} recipient(s) were sent by this + mailbox during the analysis period + {stats.foreignSentMessages > 0 + ? `, including ${stats.foreignSentMessages} from an IP outside the user's assigned usage location.` + : '.'} + + + {stats.massMailFlagged && ( + + {stats.repeatedSubjects > 0 + ? `${stats.repeatedSubjects} subject(s) were sent as many separate messages or to many recipients. ` + : ''} + {stats.sendBursts > 0 + ? `${stats.sendBursts} short burst(s) of high-volume sending were detected. ` + : ''} + Identical-subject mass mail and send bursts are how a compromised mailbox + spreads phishing or fraudulent invoices. Review the campaigns below and warn + the recipients if the content was malicious. + + )} + + {(becData?.SentMessageAnalysis?.RepeatedSubjects || []) + .slice(0, 5) + .map((group, index) => ( + + Messages: {group.MessageCount} + {'\n'} + Recipients: {group.RecipientCount} + {'\n'} + First sent: {group.FirstSent || 'N/A'} + {'\n'} + Last sent: {group.LastSent || 'N/A'} + + ))} + {(becData?.SentMessageAnalysis?.RepeatedSubjects?.length || 0) > 5 && ( + + ... and {becData.SentMessageAnalysis.RepeatedSubjects.length - 5} more repeated + subjects (see JSON export for full list) + + )} + + {(becData?.SentMessageAnalysis?.Bursts || []).slice(0, 5).map((burst, index) => ( + + Starting: {burst.WindowStart || 'N/A'} + {burst.TopSubject && `\nMost common subject: ${burst.TopSubject}`} + + ))} + {(becData?.SentMessageAnalysis?.Bursts?.length || 0) > 5 && ( + + ... and {becData.SentMessageAnalysis.Bursts.length - 5} more bursts (see JSON + export for full list) + + )} + + {becData.SentMessages.slice(0, 10).map((msg, index) => ( + + To: {msg.RecipientAddress || 'N/A'} + {'\n'} + Status: {msg.Status || 'N/A'} + {'\n'} + Received: {msg.Received || 'N/A'} + {msg.FromIP && + `\nFrom IP: ${msg.FromIP}${msg.Country ? ` (${msg.Country})` : ''}`} + {msg.ForeignLocation === true && + '\n⚠️ Sent from outside the assigned usage location'} + + ))} + {becData.SentMessages.length > 10 && ( + + ... and {becData.SentMessages.length - 10} more messages (see JSON export for + full list) + + )} + + ) : ( + + No messages were sent by this mailbox during the analysis period. + + )} +
+ + {/* Check 6: MFA Devices */} +
+ + Multi-factor authentication (MFA) devices provide an additional layer of security. + Reviewing registered MFA methods helps identify if attackers have added unauthorized + devices to bypass security controls. + + + {stats.mfaDevices > 0 ? ( + <> + + ℹ️ {stats.mfaDevices} MFA device(s) registered + {stats.recentMfaDevices > 0 + ? `, including ${stats.recentMfaDevices} registered in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.` + : '. Verify each device belongs to the user.'} + + + {[...becData.MFADevices] + .sort( + (a, b) => new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0) + ) + .slice(0, 5) + .map((device, index) => ( + + Display Name: {device.displayName || 'N/A'} + {'\n'} + Registered: {formatDate(device.createdDateTime)} + {isRecentMfaDevice(device) && '\n⚠️ Registered in the last 7 days'} + + ))} + {becData.MFADevices.length > 5 && ( + + ... and {becData.MFADevices.length - 5} more methods (see JSON export for full + list) + + )} + + ) : ( + + No multi-factor authentication devices are registered. MFA is highly recommended to + prevent unauthorized access. + + )} +
+ + {/* Check 7: Password Changes */} +
+ + Attackers often change passwords to lock out legitimate users. Reviewing recent + password changes in the tenant helps identify if the compromised account's password + was changed or if other accounts were affected. + + + {stats.passwordChanges > 0 ? ( + <> + + ℹ️ {stats.passwordChanges} password change(s) detected in the tenant during the + analysis period. + + + {becData.ChangedPasswords.slice(0, 5).map((user, index) => ( + + Email: {user.userPrincipalName || 'N/A'} + {'\n'} + Last Password Change: {formatDate(user.lastPasswordChangeDateTime)} + + ))} + {becData.ChangedPasswords.length > 5 && ( + + ... and {becData.ChangedPasswords.length - 5} more (see JSON export for full + list) + + )} + + ) : ( + + ℹ️ No password changes detected during the analysis period. + + )} +
+
+ + {/* CHECK 8, 9, 10: SENDER LISTS, DEVICES, LOCATIONS */} + + + {/* Check 8: Trusted & Blocked Senders */} +
+ + Attackers may add their own domain to the Trusted Senders list so their fraudulent + messages bypass spam filtering, or add finance/security domains to the Blocked + Senders list so warnings and alerts are hidden from the victim in the Junk Email + folder. + + + {becData?.SafelistError && ( + + {becData.SafelistError} + {'\n'} + An empty list here does not mean the mailbox has no trusted or blocked senders. + + )} + + {stats.safelistChanges > 0 && ( + <> + + The audit log recorded changes to the Trusted/Blocked Senders and Domains list on + this mailbox. Review each change carefully. + + + {becData.SafelistChanges.slice(0, 10).map((change, index) => ( + + Date: {formatDate(change.Date)} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} + {'\n'} + Trusted: {formatSafelistValue(change.Trusted)} + {'\n'} + Blocked: {formatSafelistValue(change.Blocked)} + + ))} + {becData.SafelistChanges.length > 10 && ( + + ... and {becData.SafelistChanges.length - 10} more changes (see JSON export for + full list) + + )} + + )} + + {stats.trustedSenders > 0 && ( + {becData.TrustedSenders.slice(0, 15).join(', ')} + )} + {stats.trustedSenders > 15 && ( + + ... and {stats.trustedSenders - 15} more trusted entries (see JSON export for full + list) + + )} + + {stats.blockedSenders > 0 && ( + {becData.BlockedSenders.slice(0, 15).join(', ')} + )} + {stats.blockedSenders > 15 && ( + + ... and {stats.blockedSenders - 15} more blocked entries (see JSON export for full + list) + + )} + + {!becData?.SafelistError && + stats.trustedSenders === 0 && + stats.blockedSenders === 0 && + stats.safelistChanges === 0 && ( + + No trusted or blocked sender/domain entries were found on this mailbox. + + )} +
+ + {/* Check 9: Intune Devices */} +
+ + Newly enrolled Intune devices can indicate an attacker standing up a VM or BYOD + endpoint under the compromised identity, including paths that re-register Windows + Hello for Business. Review devices enrolled during the analysis window first. + + + {becData?.IntuneDevicesError ? ( + + {becData.IntuneDevicesError} + {'\n'} + An empty device list here does not mean the user has no Intune devices. + + ) : stats.intuneDevices > 0 ? ( + <> + + ℹ️ {stats.intuneDevices} Intune-managed device(s) associated with this user + {stats.recentIntuneDevices > 0 + ? `, including ${stats.recentIntuneDevices} enrolled in the last 7 days.` + : '. None were enrolled in the last 7 days.'} + + + {sortedIntuneDevices.slice(0, 5).map((device, index) => ( + + OS: {device.operatingSystem || 'N/A'} + {device.osVersion ? ` ${device.osVersion}` : ''} + {'\n'} + Enrolled: {formatDate(device.enrolledDateTime)} + {'\n'} + Compliance: {device.complianceState || 'N/A'} + {'\n'} + Enrollment Type: {device.deviceEnrollmentType || 'N/A'} + {device.serialNumber ? `\nSerial: ${device.serialNumber}` : ''} + + ))} + {sortedIntuneDevices.length > 5 && ( + + ... and {sortedIntuneDevices.length - 5} more devices (see JSON export for full + list) + + )} + + ) : ( + + No Intune-managed devices were found for this user. + + )} +
+ + {/* Check 10: Sign-in Locations */} +
+ + Sign-ins from countries the user does not work from are one of the strongest + compromise indicators. Each sign-in is compared against the user's assigned usage + location in Entra ID + {locationAnalysis?.UsageLocation ? ` (${locationAnalysis.UsageLocation})` : ''}, and + the client IPs behind rule changes, safelist changes, sharing changes, and sent mail + are geo-located and compared the same way. + + + {becData?.SuspectUserSignInsError ? ( + + {becData.SuspectUserSignInsError} + {'\n'} + An empty list here does not mean the user has not signed in. + + ) : ( + <> + {!locationAnalysis?.UsageLocation && ( + + {locationAnalysis?.Note || + 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'} + + )} + + {(locationAnalysis?.SignInCountries?.length || 0) > 0 && ( + + {locationAnalysis.SignInCountries.map( + (c) => `${c.Country}: ${c.Count} sign-in(s)` + ).join('\n')} + + )} + + {stats.foreignSignIns > 0 || stats.foreignActivity > 0 ? ( + <> + + {stats.foreignSignIns} sign-in(s) (of which {stats.foreignSuccessfulSignIns}{' '} + succeeded), {locationAnalysis?.ForeignRuleChangeCount || 0} inbox rule + change(s), {locationAnalysis?.ForeignSafelistChangeCount || 0} safelist + change(s), {locationAnalysis?.ForeignSharingChangeCount || 0} sharing + change(s), and {locationAnalysis?.ForeignSentMessageCount || 0} sent + message(s) originated outside {locationAnalysis?.UsageLocation}. Failed + foreign sign-ins are mostly password-spray noise; the successful ones prove + access. Review each carefully — a single legitimate trip can explain some of + this, but rule, safelist, or sharing changes from a foreign IP rarely have an + innocent explanation. + + + {foreignSignIns.slice(0, 10).map((signIn, index) => ( + + Application: {signIn.AppDisplayName || 'N/A'} + {'\n'} + IP Address: {signIn.IPAddress || 'N/A'} + {'\n'} + City: {signIn.City || 'N/A'} + {'\n'} + Result: {signIn.Status || 'N/A'} + + ))} + {foreignSignIns.length > 10 && ( + + ... and {foreignSignIns.length - 10} more foreign sign-ins (see JSON export + for full list) + + )} + + ) : locationAnalysis?.UsageLocation ? ( + + All located sign-ins and activity match the user's assigned usage location ( + {locationAnalysis.UsageLocation}). + + ) : null} + + )} +
+ + {/* Check 11: Sharing Links */} +
+ + Attackers share OneDrive and SharePoint folders to give themselves a data feed that + survives a password reset, and anonymous links expose the content to anyone holding + the URL. This check lists every sharing link the account created or changed during + the analysis period, including the IP address it was done from. + + + {stats.sharingChanges > 0 ? ( + <> + + {stats.anonymousLinks > 0 + ? `${stats.anonymousLinks} of these involve anonymous links, which anyone with the URL can open. ` + : ''} + Review each link and remove any that are not explained, even if the account has + since been remediated. + + + {becData.SharingChanges.slice(0, 10).map((change, index) => ( + + Date: {formatDate(change.Date)} + {'\n'} + Workload: {change.Workload || 'N/A'} + {change.Target && `\nShared with: ${change.Target}`} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} + + ))} + {becData.SharingChanges.length > 10 && ( + + ... and {becData.SharingChanges.length - 10} more changes (see JSON export for + full list) + + )} + + ) : ( + + No sharing links were created or changed by this account during the analysis + period. + + )} +
+
+ + {/* RECOMMENDATIONS PAGE */} + + +
+ + Based on the investigation findings, the following actions should be taken immediately: + + + + Change the user's + password immediately to prevent further unauthorized access. + + Sign out the user from + all active sessions to terminate any attacker access. + + Delete any + mailbox rules that forward, redirect, or hide emails, especially those moving + messages to unusual folders. + + Remove any MFA + devices that the user doesn't recognize and re-register legitimate devices. + + Review and revoke any + unauthorized mailbox permissions or application consents. + + Continue monitoring the + account for suspicious activity for at least 30 days. + + +
+ +
+ + To prevent future Business Email Compromise attacks, implement these security best + practices: + + + + {' '} + Require MFA for all users, especially those with administrative privileges or access + to financial systems. + + {' '} + Educate employees about phishing, social engineering, and how to identify suspicious + emails. Regular training significantly reduces successful attacks. + + Use + email security solutions that detect and block phishing, malware, and suspicious + attachments. + + {' '} + Restrict access based on location, device compliance, and risk level to prevent + unauthorized sign-ins. + + Regularly review + audit logs for suspicious activities such as unusual sign-in patterns, rule + creation, or permission changes. + + Implement + multi-person approval processes for wire transfers and payment changes to prevent + fraudulent transactions. + + +
+ +
+ + Share these key points with the affected user to help prevent future compromises: + + + + + Never click on links or open attachments in unexpected emails, even if they appear + to come from known contacts. + + + Always verify unusual requests for money transfers or sensitive information through + a separate communication channel (phone call, in person). + + + Use strong, unique passwords for each account and consider using a password manager. + + + Be cautious when authorizing new applications or granting permissions to third-party + services. + + + Report suspicious emails or activities to your IT security team immediately. + + +
+
+ + {/* COMPLIANCE & DOCUMENTATION PAGE */} + + +
+ + This report supports compliance and documentation requirements for various security + frameworks and regulatory standards: + + + + Demonstrates incident + detection, analysis, and response procedures (Controls A.16.1.1 - A.16.1.7). + + Provides evidence of + security incident monitoring, analysis, and documentation (AC.L2-3.1.12, + AU.L2-3.3.1). + + Documents detective and + responsive controls for security incidents (CC7.3, CC7.4). + + Aligns with Detect (DE.AE, + DE.CM) and Respond (RS.AN, RS.MI) functions. + + Demonstrates security breach + detection and potential data breach assessment (Articles 32, 33). + + +
+ +
+ + This investigation and resulting documentation provide an audit trail for security + incident response: + + + + Investigation Date: {formatDate(becData?.ExtractedAt)} + {'\n'} + Analyzed User: {userData?.userPrincipalName} + {'\n'} + Organization: {tenantName} + {'\n'} + Analysis Period: 7 days + {'\n'} + Assigned Usage Location: {locationAnalysis?.UsageLocation || 'Not assigned'} + {'\n'} + Audit Log Status: {becData?.ExtractResult || 'Unknown'} + + + + Threat Level: {threatLevel.level} + {'\n'} + Mailbox Rules Found: {stats.newRules} + {'\n'} + Rule Changes: {stats.ruleChanges} + {'\n'} + Permission Changes: {stats.permissionChanges} ({stats.permissionChangesTargetingUser}{' '} + targeting this mailbox) + {'\n'} + New Applications: {stats.newApps} + {'\n'} + Known-Malicious Applications: {stats.maliciousApps} + {'\n'} + New Users: {stats.newUsers} + {'\n'} + Sent Messages: {stats.sentTotalMessages || stats.sentMessages} + {'\n'} + Repeated Subject Campaigns: {stats.repeatedSubjects} + {'\n'} + Send Bursts: {stats.sendBursts} + {'\n'} + MFA Devices: {stats.mfaDevices} + {'\n'} + Recent MFA Registrations (7d): {stats.recentMfaDevices} + {'\n'} + Password Changes: {stats.passwordChanges} + {'\n'} + Trusted Senders: {stats.trustedSenders} + {'\n'} + Blocked Senders: {stats.blockedSenders} + {'\n'} + Safelist Changes: {stats.safelistChanges} + {'\n'} + Sharing Changes: {stats.sharingChanges} + {'\n'} + Anonymous Links: {stats.anonymousLinks} + {'\n'} + Intune Devices: {stats.intuneDevices} + {'\n'} + Recent Intune Enrollments (7d): {stats.recentIntuneDevices} + {'\n'} + Foreign Sign-ins: {stats.foreignSignIns} ({stats.foreignSuccessfulSignIns} successful) + {'\n'} + Foreign Rule/Safelist/Sharing/Mail Activity: {stats.foreignActivity} + +
+ +
+ + This report should be retained according to your organization's document retention + policy and regulatory requirements. Typical retention periods range from 3-7 years + depending on applicable compliance frameworks. Store this document securely with + restricted access as it contains sensitive security information. + +
+ +
+ + For more information about Business Email Compromise and cybersecurity best practices: + + + + + FBI IC3: Internet Crime Complaint Center (ic3.gov) + + + CISA: Cybersecurity & Infrastructure Security Agency (cisa.gov) + + + Microsoft Security: Business Email Compromise resources + + +
+
+
+ ) +} + +// Main Button Component +export const BECRemediationReportButton = ({ userData, becData, tenantName }) => { + const [dialogOpen, setDialogOpen] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) + + // Check if we have the necessary data + const hasData = userData && becData && !becData.Waiting + + const brandingSettings = useBrandingSettings() + const variables = useReportVariables() + + const handleOpenDialog = () => { + setDialogOpen(true) + } + + const handleCloseDialog = () => { + setDialogOpen(false) + } + + if (!hasData) { + return null // Don't show button if data isn't ready + } + + return ( + <> + + + + + + + + + BEC Remediation Report Preview + + + + + + + + {hasData && ( + + + + )} + + + + + } + fileName={`BEC_Report_${userData?.userPrincipalName}_${new Date().toISOString().split('T')[0]}.pdf`} + style={{ textDecoration: 'none' }} + > + {({ loading }) => ( + + )} + + + + + ); +} \ No newline at end of file diff --git a/src/components/CSVReader.jsx b/src/components/CSVReader.jsx index be3f6f67f02a..7b9be8f0cadf 100644 --- a/src/components/CSVReader.jsx +++ b/src/components/CSVReader.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; +import { CippIcons } from "../utils/icon-registry"; import { useCSVReader, lightenDarkenColor, formatFileSize } from "react-papaparse"; import { Box, Typography, useTheme } from "@mui/material"; -import { CloudUpload } from "@mui/icons-material"; /* * These colors define our remove button states. The light version is @@ -94,7 +94,7 @@ export default function CSVReader({ config, onDrop, onRemove }) { {(acceptedFile || storedFile) ? ( - + {acceptedFile?.name || storedFile?.name} @@ -139,7 +139,7 @@ export default function CSVReader({ config, onDrop, onRemove }) { ) : ( - { title={title} subheader={subheader} action={action} - titleTypographyProps={{ variant: 'h6' }} - subheaderTypographyProps={{ variant: 'caption' }} - /> + slotProps={{ + title: { variant: 'h6' }, + subheader: { variant: 'caption' } + }} /> {isError ? ( @@ -47,7 +43,7 @@ const DashboardCard = ({ title, subheader, api, children, action }) => { )} - ) + ); } export const AllTenantsDashboard = () => { @@ -82,7 +78,7 @@ export const AllTenantsDashboard = () => { { name: 'Tenants', data: tenantCount.toLocaleString(), - icon: , + icon: , toolTip: 'Tenants under management. Select to open the tenant list.', link: '/tenant/administration/tenants', }, @@ -91,11 +87,11 @@ export const AllTenantsDashboard = () => { data: item.value.toLocaleString(), icon: item.label === 'Users' ? ( - + ) : item.label === 'Mailboxes' ? ( - + ) : ( - + ), toolTip: `${item.average.toLocaleString()} per tenant on average. Select to open the list.`, link: scaleLinks[item.label], @@ -122,7 +118,13 @@ export const AllTenantsDashboard = () => { data={portfolioBarItems} /> {!countsApi.isLoading && !cache.hasData && ( - + No cached collections were returned. The nightly cache job may not have run yet. )} @@ -143,12 +145,20 @@ export const AllTenantsDashboard = () => { } > - + {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/components/CippComponents/CippDriftManagementActions.jsx b/src/components/CippComponents/CippDriftManagementActions.jsx new file mode 100644 index 000000000000..41f202b9ad2a --- /dev/null +++ b/src/components/CippComponents/CippDriftManagementActions.jsx @@ -0,0 +1,99 @@ +import React from 'react' +import { CippIcons } from '../../utils/icon-registry' +import { CippFormTemplateTenantSelector } from './CippFormTemplateTenantSelector.jsx' + +/** + * Creates the standard drift management actions array + * @param {Object} options - Configuration options + * @param {string} options.templateId - The template ID for conditional actions + * @param {Function} options.onRefresh - Function to call when refresh is triggered + * @param {Function} options.onGenerateReport - Function to call when generate report is triggered (optional) + * @returns {Array} Array of action objects + */ +export const createDriftManagementActions = ({ + templateId, + templateType = 'classic', + showEditTemplate = false, + onRefresh, + onGenerateReport, + currentTenant, + templateTenants = [], + excludedTenants = [], +}) => { + const actions = [ + { + label: 'Refresh Data', + icon: , + noConfirm: true, + customFunction: onRefresh, + }, + ] + + // Add Generate Report action if handler is provided + if (onGenerateReport) { + actions.push({ + label: 'Generate Report', + icon: , + noConfirm: true, + customFunction: onGenerateReport, + }) + } + + // Add template-specific actions if templateId is available + if (templateId) { + // Conditionally add Edit Template action + if (showEditTemplate) { + actions.push({ + label: 'Edit Template', + pinned: true, + icon: , + color: 'info', + noConfirm: true, + customFunction: () => { + // Use Next.js router for internal navigation + import('next/router') + .then(({ default: router }) => { + router.push( + `/tenant/standards/templates/template?id=${templateId}&type=${templateType}` + ) + }) + .catch(() => { + // Fallback to window.location if router is not available + window.location.href = `/tenant/standards/templates/template?id=${templateId}&type=${templateType}` + }) + }, + }) + } + + actions.push({ + label: 'Run Standard Now', + type: 'GET', + url: '/api/ExecStandardsRun', + icon: , + data: { + TemplateId: templateId, + }, + customDataformatter: (_row, _action, formData) => ({ + TemplateId: templateId, + tenantFilter: formData.tenantFilter?.value ?? formData.tenantFilter, + }), + children: ({ formHook }) => ( + + ), + confirmText: 'Are you sure you want to force a run of this standard?', + allowResubmit: true, + multiPost: false, + }) + } + + return actions +} + +/** + * Default export for backward compatibility + */ +export default createDriftManagementActions 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 = ({ - )} - - ) - - // feeds both CippMobileTableControls (cards) and CippTableFilterSheet (table branch) - const mobileColumnItems = table - .getAllColumns() - .filter((column) => !column.id.startsWith('mrt-')) - .map((column) => ({ - id: column.id, - visible: Boolean(column.getIsVisible()), - })) - const handleToggleColumn = (columnId, visible) => - setColumnVisibility({ ...columnVisibility, [columnId]: !visible }) - const handleExportCsvClick = () => - document.querySelector(`[data-csv-export="${title}"]`)?.click() - const handleExportPdfClick = () => - document.querySelector(`[data-pdf-export="${title}"]`)?.click() - const handleViewApiResponse = () => - isInDialog ? setJsonDialogOpen(true) : setOffcanvasVisible(true) - const handleEditGraphFilters = - api?.url === '/api/ListGraphRequest' ? () => setFilterCanvasVisible(true) : undefined - const handleResetFilters = () => setTableFilter('', 'reset', '') - const mobileIsRefreshing = Boolean( - getRequestData?.isFetching || refreshFunction?.isFetching - ) - - return ( - <> - {viewMode === 'cards' ? ( - - ) : undefined - } - dataSourceControls={dataSourceControls} - /> - ) : ( - <> - - {/* Left side - Main controls */} - - {/* phones refresh from the options sheet instead */} - {!mdDown && ( - - - - - {getRequestData?.isFetchNextPageError ? ( - - ) : ( - - )} - - - - - )} - - {/* Search Input */} - - - - - - {/* Desktop Buttons - always render for measurement, hide when in compact mode */} - {!mdDown && ( - - {/* Filters Button */} - } - endIcon={} - onClick={(event) => setFiltersAnchor(event.currentTarget)} - sx={{ - color: - activeSlotCount > 0 ? 'primary.main' : 'text.primary', - borderColor: - activeSlotCount > 0 ? 'primary.main' : undefined, - }} - > - {activeSlotCount > 0 - ? `Filters (${activeSlotCount})` - : 'Filters'} - - - {/* Columns Button */} - } - endIcon={} - onClick={(event) => setColumnsAnchor(event.currentTarget)} - > - Columns - - setColumnsAnchor(null)} - PaperProps={{ - sx: { - mt: 1, - borderRadius: 2, - minWidth: 250, - maxHeight: 400, - }, - }} - > - - - - - - - - - - - {table - .getAllColumns() - .filter((column) => !column.id.startsWith('mrt-')) - .map((column) => ( - - setColumnVisibility({ - ...columnVisibility, - [column.id]: !column.getIsVisible(), - }) - } - > - - - - ))} - - - {/* Export Button */} - {exportEnabled && ( - } - endIcon={} - onClick={(event) => setExportAnchor(event.currentTarget)} - > - Export - - )} - - )} - - {/* Compact Action Button — desktop compact mode only, the phone table uses the filter sheet */} - {!mdDown && useCompactMode && !hasSelection && ( - setActionMenuAnchor(event.currentTarget)} - sx={{ flexShrink: 0 }} - > - - - )} - - {/* phones keep the kebab open regardless of selection, the only route to the - sheet (refresh, export, rows-per-page) down there, not just filters */} - {(mdDown || (useCompactMode && !hasSelection)) && ( - { - if (mdDown) { - setMobileFilterSheetOpen(true) - return - } - setFiltersAnchor(event.currentTarget) - }} - sx={{ - flexShrink: 0, - ...(mdDown && activeSlotCount > 0 && { color: 'primary.main' }), - }} - > - {mdDown ? ( - - - - ) : ( - - )} - - )} - - {/* way back to cards, far right to match the card bar's toggle position */} - {tableViewActive && showReturnToCards && ( - - - - {/* destination icon: tapping here returns to cards */} - - - - - )} - - {/* Compact Action Menu — desktop compact mode only */} - setActionMenuAnchor(null)} - PaperProps={{ - sx: { - mt: 1, - borderRadius: 2, - minWidth: 180, - }, - }} - > - {/* Anchor the nested menus to the stable overflow IconButton — anchoring to - event.currentTarget here targets a MenuItem inside a menu that closes in - the same tick, which positions the next popover unpredictably. */} - { - setColumnsAnchor(actionMenuAnchor) - setActionMenuAnchor(null) - }} - > - - - - Columns - - {exportEnabled && ( - { - setExportAnchor(actionMenuAnchor) - setActionMenuAnchor(null) - }} - > - - - - Export - - )} - { - table.setIsFullScreen(!table.getState().isFullScreen) - setActionMenuAnchor(null) - }} - > - - - - - {table.getState().isFullScreen - ? 'Exit Fullscreen' - : 'Fullscreen'} - - - - - {/* Filters Menu */} - setFiltersAnchor(null)} - PaperProps={{ - sx: { - mt: 1, - borderRadius: 2, - minWidth: 200, - }, - }} - > - { - handleColumnFiltersToggle() - setFiltersAnchor(null) - }} - > - - {table.getState().showColumnFilters - ? 'Hide Column Filters' - : 'Show Column Filters'} - - - - setTableFilter('', 'reset', '')}> - - - {api?.url === '/api/ListGraphRequest' && ( - { - setFiltersAnchor(null) - setFilterCanvasVisible(true) - }} - > - - - )} - {showFilterSections && ( - Graph filters - )} - {!showFilterSections && graphPresetItems.length > 0 && ( - - )} - {graphPresetItems.map((filter) => - renderPresetItem(filter, 'graph') - )} - {showFilterSections && ( - Table filters - )} - {!showFilterSections && tablePresetItems.length > 0 && ( - - )} - {tablePresetItems.map((filter) => - renderPresetItem(filter, 'table') - )} - - - {/* Columns Menu */} - setColumnsAnchor(null)} - PaperProps={{ - sx: { - mt: 1, - borderRadius: 2, - minWidth: 250, - maxHeight: 400, - }, - }} - > - - - - - - - - - - - {table - .getAllColumns() - .filter((column) => !column.id.startsWith('mrt-')) - .map((column) => ( - - setColumnVisibility({ - ...columnVisibility, - [column.id]: !column.getIsVisible(), - }) - } - > - - - - ))} - - - {/* Export Menu */} - {exportEnabled && ( - setExportAnchor(null)} - PaperProps={{ - sx: { - mt: 1, - borderRadius: 2, - minWidth: 180, - }, - }} - > - { - // Trigger CSV export - const csvButton = document.querySelector( - `[data-csv-export="${title}"]` - ) - if (csvButton) csvButton.click() - setExportAnchor(null) - }} - > - - - - - - { - // Trigger PDF export - const pdfButton = document.querySelector( - `[data-pdf-export="${title}"]` - ) - if (pdfButton) pdfButton.click() - setExportAnchor(null) - }} - > - - - - - - {builtInBulkExportAvailable && ( - <> - - { - handleExportSelectedToCsv() - setExportAnchor(null) - }} - > - - - - - - { - handleExportSelectedToPdf() - setExportAnchor(null) - }} - > - - - - - - - )} - - { - if (isInDialog) { - setJsonDialogOpen(true) - } else { - setOffcanvasVisible(true) - } - setExportAnchor(null) - }} - > - - - - - - - )} - - - {/* Right side - Additional controls */} - - {/* Selected rows indicator + Bulk Actions - inline, unless portaled into the header */} - {!bulkActionsSlot && bulkActionsContent} - - {/* Queue tracker */} - - - - - table.setPageSize(size)} - pageSizeOptions={[25, 50, 100, 250, 500]} - dataSourceControls={dataSourceControls} - /> - - )} - - {bulkActionsSlot && createPortal(bulkActionsContent, bulkActionsSlot)} - - {/* Hidden export buttons for triggering — outside the mode branch so the - mobile filter sheet's export items can click them too */} - - - - - - {/* Bulk Actions Menu - now inline with toolbar */} - - {actions && - customBulkActions.map((action, index) => ( - handleBulkAction(action, popover.handleClose)} - > - - {action.icon} - - {action.label} - - ))} - - - {/* API Response Off-Canvas - only show when not in dialog mode */} - {!isInDialog && ( - { - setOffcanvasVisible(false) - }} - > - - - - - )} - - {/* Action Dialog */} - {actionData.ready && ( - - )} - - {/* Graph Filter Off-Canvas */} - setFilterCanvasVisible(!filterCanvasVisible)} - contentPadding={1} - keepMounted={true} - aboveModal={isInDialog} - > - presetKey(f) === activeFilters.graph.id - ) ?? null) - : null - } - onPresetSelect={(preset) => { - if (preset?.value && preset?.type === 'graph') { - setTableFilter( - preset.value, - preset.type, - preset.filterName, - preset.id - ) - } - }} - onSubmitFilter={(filter) => { - setTableFilter(filter, 'graph', 'Custom Filter') - setFilterCanvasVisible(false) - if (filter?.$select) { - let selectedColumns = [] - if (Array.isArray(filter?.$select)) { - selectedColumns = filter?.$select - } else if (typeof filter?.$select === 'string') { - selectedColumns = filter.$select.split(',') - } - if (selectedColumns.length > 0) { - setConfiguredSimpleColumns(selectedColumns) - selectedColumns.forEach((col) => { - setNestedVisibility(col) - }) - } - } else { - setConfiguredSimpleColumns(originalSimpleColumns) - } - }} - component="card" - /> - - - {/* JSON Dialog for when in dialog mode */} - {isInDialog && ( - setJsonDialogOpen(false)} - sx={{ zIndex: (theme) => theme.zIndex.modal + 1 }} - > - API Response - - - - - - - - )} - - ) - } -) - -CIPPTableToptoolbar.displayName = 'CIPPTableToptoolbar' diff --git a/src/components/CippTable/CIPPTableToptoolbar.jsx b/src/components/CippTable/CIPPTableToptoolbar.jsx new file mode 100644 index 000000000000..30cbe5824bd1 --- /dev/null +++ b/src/components/CippTable/CIPPTableToptoolbar.jsx @@ -0,0 +1,1788 @@ +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react' +import { CippIcons } from '../../utils/icon-registry' +import { createPortal } from 'react-dom' +import { + Badge, + Box, + Button, + Menu, + MenuItem, + ListItemText, + ListItemIcon, + ListSubheader, + Divider, + IconButton, + Tooltip, + Typography, + Checkbox, + SvgIcon, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from '@mui/material' +import { PDFExportButton, exportRowsToPdf } from '../pdfExportButton' +import { CSVExportButton, exportRowsToCsv } from '../csvExportButton' +import { getCippTranslation } from '../../utils/get-cipp-translation' +import { useMediaQuery } from '@mui/material' +import { CippQueueTracker } from './CippQueueTracker' +import { usePopover } from '../../hooks/use-popover' +import { useDialog } from '../../hooks/use-dialog' +import { CippApiDialog } from '../CippComponents/CippApiDialog' +import { useSettings } from '../../hooks/use-settings' +import { attachParentRow } from '../../utils/resolve-row-templates' +import { fetchBrandingSettings } from '../CippPdf/useBrandingSettings' +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/router' +import { CippOffCanvas } from '../CippComponents/CippOffCanvas' +import { CippCodeBlock } from '../CippComponents/CippCodeBlock' +import { ApiGetCall } from '../../api/ApiCall' +import GraphExplorerPresets from '../../data/GraphExplorerPresets.json' +import CippGraphExplorerFilter from './CippGraphExplorerFilter' +import { Stack } from '@mui/system' +import { CippMobileTableControls } from './CippMobileTableControls' +import { CippTableFilterSheet } from './CippTableFilterSheet' +import { useSheetHandoff } from '../../hooks/use-sheet-handoff' +import { + ModernSearchContainer, + ModernSearchInput, + ModernButton, + RefreshButton, +} from './toolbar-primitives' + +export const CIPPTableToptoolbar = React.memo( + ({ + api, + simpleColumns, + queryKey, + table, + getRequestData, + usedColumns, + usedData, + columnVisibility, + setColumnVisibility, + title, + actions, + filters = [], + exportEnabled, + refreshFunction, + queryKeys, + data, + setGraphFilterData, + setConfiguredSimpleColumns, + queueMetadata, + isInDialog = false, + embedded = false, + showBulkExportAction = true, + // Mobile card mode: same state, same handlers, different presentation (sheets + // instead of menus). Select-mode state lives in CippDataTable so the card list + // and this toolbar stay in sync. + viewMode = 'table', + selectMode = false, + onSelectModeChange, + selectModeLocked = false, + onViewToggle, + tableViewActive = false, + showReturnToCards = false, + // when set, the selection count + Bulk Actions button portal into this node + // (the Card header's slot) rather than rendering inline in the toolbar + bulkActionsSlot = null, + // Live/Cached data-source controls, rendered in the mobile Table options sheet + dataSourceControls, + // Owned by CippDataTable: this toolbar mounts as two alternating instances (the cards + // branch and the renderTopToolbar branch), so state that must survive the cards<->table + // flip is passed down as props rather than kept in local useState/useRef here. + activeFilters = { graph: null, table: null }, + setActiveFilters, + searchValue = '', + setSearchValue, + restoredFiltersRef, + persistenceKey, + parentRow, + }) => { + const popover = usePopover() + const [filtersAnchor, setFiltersAnchor] = useState(null) + const [columnsAnchor, setColumnsAnchor] = useState(null) + const [exportAnchor, setExportAnchor] = useState(null) + const [actionMenuAnchor, setActionMenuAnchor] = useState(null) + const [mobileFilterSheetOpen, setMobileFilterSheetOpen] = useState(false) + // table branch's own handoff instance — the cards branch (CippMobileTableControls) owns a separate one + const mobileFilterSheet = useSheetHandoff(() => setMobileFilterSheetOpen(false)) + + const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) + const settings = useSettings() + const queryClient = useQueryClient() + const router = useRouter() + const createDialog = useDialog() + const [actionData, setActionData] = useState({ + data: {}, + action: {}, + ready: false, + }) + const [offcanvasVisible, setOffcanvasVisible] = useState(false) + const [jsonDialogOpen, setJsonDialogOpen] = useState(false) // For dialog-based JSON view + const [filterList, setFilterList] = useState(filters) + const [currentEffectiveQueryKey, setCurrentEffectiveQueryKey] = useState( + queryKey || title + ) + const [originalSimpleColumns, setOriginalSimpleColumns] = + useState(simpleColumns) + const [filterCanvasVisible, setFilterCanvasVisible] = useState(false) + const presetKey = (filter) => filter?.id ?? filter?.filterName + const pageName = persistenceKey ?? (isInDialog ? '' : router.pathname.split('/').slice(1).join('/')) + const [useCompactMode, setUseCompactMode] = useState(false) + const toolbarRef = useRef(null) + const leftContainerRef = useRef(null) + const actionsContainerRef = useRef(null) + + const wrapActionRow = (original) => attachParentRow(original, parentRow) + + const getBulkActions = (actions, selectedRows) => { + return ( + actions + // customComponent actions are single-row dialogs; the bulk path renders CippApiDialog + // unconditionally, so admitting one here produces an empty dialog with no API behind it. + ?.filter((action) => !action.link && !action?.hideBulk && !action?.customComponent) + ?.map((action) => ({ + ...action, + // bulkFilterEligible actions run against the eligible subset of the selection: + // available when ANY selected row qualifies, and dispatch filters the rest out. + // The default stays all-or-nothing (every selected row must qualify). + disabled: action.condition + ? action.bulkFilterEligible + ? !selectedRows.some((row) => action.condition(wrapActionRow(row.original))) + : !selectedRows.every((row) => action.condition(wrapActionRow(row.original))) + : false, + })) || [] + ) + } + + const selectedRows = table.getSelectedRowModel().rows + const hasSelection = + table.getIsSomeRowsSelected() || table.getIsAllRowsSelected() + // Built-in export actions should only appear when the page opts in and rows are selected. + const builtInBulkExportAvailable = + showBulkExportAction && exportEnabled && selectedRows.length > 0 + const customBulkActions = getBulkActions(actions, selectedRows) + const showBulkActionsButton = hasSelection && customBulkActions.length > 0 + + const handleExportSelectedToCsv = () => { + if (!selectedRows.length) { + return + } + exportRowsToCsv({ + rows: selectedRows, + columns: usedColumns, + reportName: `${title}`, + columnVisibility, + }) + } + + const handleExportSelectedToPdf = async () => { + if (!selectedRows.length) { + return + } + exportRowsToPdf({ + rows: selectedRows, + columns: usedColumns, + reportName: `${title}`, + columnVisibility, + brandingSettings: await fetchBrandingSettings(queryClient), + }) + } + + // Shared refresh dispatch — desktop refresh button and the mobile filter sheet. + const handleRefresh = () => { + if (typeof refreshFunction === 'object') { + refreshFunction.refetch() + } else if (typeof refreshFunction === 'function') { + refreshFunction() + } else if (data && !getRequestData.isFetched) { + // do nothing because data was sent native. + } else if (getRequestData) { + getRequestData.refetch() + } + } + + // Shared bulk-action dispatch — desktop bulk menu and the mobile bulk sheet must not + // drift, so both route through here. + const handleBulkAction = (action, closeMenu = () => {}) => { + if (action.disabled) { + return + } + + const allSelectedRows = table.getSelectedRowModel().rows + const eligibleRows = + action.bulkFilterEligible && action.condition + ? allSelectedRows.filter((row) => action.condition(wrapActionRow(row.original))) + : allSelectedRows + const selectedData = eligibleRows.map((row) => wrapActionRow(row.original)) + + if (typeof action.customBulkHandler === 'function') { + action.customBulkHandler({ + rows: eligibleRows, + data: selectedData, + closeMenu, + clearSelection: () => table.toggleAllRowsSelected(false), + }) + closeMenu() + return + } + + // Runs before any state change: setting ready:true first mounts CippApiDialog with + // api.noConfirm true, and its mount effect auto-submits into the same customFunction + // being called here — every selected row's action fired twice. + if (action?.noConfirm && action.customFunction) { + // multiPost actions expect the full selection in one call (e.g. Edit Properties + // stores users in sessionStorage then navigates). Per-row invocation would + // overwrite that state with only the last selected row. + if (action.multiPost) { + action.customFunction(selectedData, action, {}) + } else { + eligibleRows.forEach((row) => + action.customFunction(wrapActionRow(row.original.original ?? row.original), action, {}) + ) + } + // Deliberately no closeMenu() here — that matches the behaviour this branch had + // before; the only thing being fixed is the duplicate invocation. + return + } + + setActionData({ + data: selectedData, + action: action, + ready: true, + }) + createDialog.handleOpen() + closeMenu() + } + + // Sync currentEffectiveQueryKey with queryKey prop changes (e.g., tenant changes) — a + // plain re-derivation from the same props this instance was given, harmless on remount + useEffect(() => { + setCurrentEffectiveQueryKey(queryKey || title) + }, [queryKey, title]) + + useEffect(() => { + setOriginalSimpleColumns(simpleColumns) + }, [simpleColumns]) + + // Early restoration of graph filters (before API call) - run only once per page + useEffect(() => { + const restorationKey = `${pageName}-graph` + + if ( + pageName && + settings.persistFilters && + settings.lastUsedFilters && + settings.lastUsedFilters[pageName] && + api?.url === '/api/ListGraphRequest' && // Only for graph requests + !restoredFiltersRef.current.has(restorationKey) // Only if not already restored + ) { + const last = normalizePersistedFilters( + settings.lastUsedFilters[pageName] + ).graph + if (last) { + // Mark as restored to prevent infinite loops + restoredFiltersRef.current.add(restorationKey) + + // Directly set the graph filter data without calling setTableFilter to avoid loops + const filterProps = [ + '$filter', + '$select', + '$expand', + '$orderby', + '$count', + '$search', + 'ReverseTenantLookup', + 'ReverseTenantLookupProperty', + 'AsApp', + ] + const graphFilter = filterProps.reduce((acc, prop) => { + if (last.value[prop]) { + acc[prop] = last.value[prop] + } + return acc + }, {}) + const resolvedGraphFilter = resolveFilterVariables(graphFilter) + + const newQueryKey = `${queryKey ? queryKey : title}-${last.name}` + setGraphFilterData({ + data: { ...mergeCaseInsensitive(api.data, resolvedGraphFilter) }, + queryKey: newQueryKey, + }) + setCurrentEffectiveQueryKey(newQueryKey) + setActiveFilters((prev) => ({ + ...prev, + graph: { id: last.id, name: last.name }, + })) + + if (last.value?.$select) { + let selectColumns = [] + if (Array.isArray(last.value.$select)) { + selectColumns = last.value.$select + } else if (typeof last.value.$select === 'string') { + selectColumns = last.value.$select + .split(',') + .map((col) => col.trim()) + .filter((col) => usedColumns.includes(col)) + } + if (selectColumns.length > 0) { + setConfiguredSimpleColumns(selectColumns) + } + } + } + } + }, [ + settings.persistFilters, + settings.lastUsedFilters, + pageName, + api?.url, + queryKey, + title, + ]) + + // Detect overflow and switch to compact mode + useEffect(() => { + const checkOverflow = () => { + if (!leftContainerRef.current || !actionsContainerRef.current) { + return + } + + const leftContainerWidth = leftContainerRef.current.offsetWidth + const leftContainerScrollWidth = leftContainerRef.current.scrollWidth + const actionsWidth = actionsContainerRef.current.scrollWidth + const isOverflowing = leftContainerScrollWidth > leftContainerWidth + const shouldBeCompact = + isOverflowing || actionsWidth > leftContainerWidth * 0.6 // Actions taking > 60% of left container + + setUseCompactMode(shouldBeCompact) + } + + // Check immediately on mount and when dependencies change + checkOverflow() + + // Also check after a brief delay to ensure elements are fully rendered + const timeoutId = setTimeout(checkOverflow, 100) + + const resizeObserver = new ResizeObserver(checkOverflow) + if (leftContainerRef.current) { + resizeObserver.observe(leftContainerRef.current) + } + + return () => { + clearTimeout(timeoutId) + resizeObserver.disconnect() + } + }, [ + hasSelection, + customBulkActions.length, + exportEnabled, + filters?.length, + usedColumns?.length, + ]) + + // Restore last used filter on mount if persistFilters is enabled (non-graph filters). + // Once-per-page like the graph slot above: keying this on isFetching used to re-arm the + // 100ms timer on every fetch settle (once per page of an auto-paginated load), clobbering + // whatever filter the user had just applied with the persisted one. + useEffect(() => { + const restorationKey = `${pageName}-table` + // Wait for table to be initialized and columns to exist (column filters need them) + if ( + pageName && + settings.persistFilters && + settings.lastUsedFilters && + settings.lastUsedFilters[pageName] && + table && + usedColumns.length > 0 && + !restoredFiltersRef.current.has(restorationKey) + ) { + // Use setTimeout to ensure the table is fully rendered + const timeoutId = setTimeout(() => { + const last = normalizePersistedFilters( + settings.lastUsedFilters[pageName] + ).table + if (!last) { + return + } + + if (last.type === 'global') { + restoredFiltersRef.current.add(restorationKey) + table.setGlobalFilter(last.value) + // Keep the visible search box in sync with the filter it now represents + setSearchValue(typeof last.value === 'string' ? last.value : '') + setActiveFilters((prev) => ({ + ...prev, + table: { id: last.id, name: last.name, type: last.type }, + })) + } else if (last.type === 'column') { + // Only apply if all filter columns exist in the current table; if they don't + // yet (columns still streaming in), leave unmarked so a later run retries. + const allColumns = table.getAllColumns().map((col) => col.id) + const filterColumns = Array.isArray(last.value) + ? last.value.map((f) => f.id) + : [] + const allExist = filterColumns.every((colId) => + allColumns.includes(colId) + ) + if (allExist) { + restoredFiltersRef.current.add(restorationKey) + if (viewMode !== 'cards') { + table.setShowColumnFilters(true) + } + table.setColumnFilters(last.value) + setActiveFilters((prev) => ({ + ...prev, + table: { id: last.id, name: last.name, type: last.type }, + })) + } + } + // Note: graph filters are handled in the earlier useEffect + }, 100) + + return () => clearTimeout(timeoutId) + } + }, [ + settings.persistFilters, + settings.lastUsedFilters, + pageName, + table, + usedColumns, + viewMode, + ]) + + const presetList = ApiGetCall({ + url: '/api/ListGraphExplorerPresets', + queryKey: `ListGraphExplorerPresets${api?.data?.Endpoint ?? ''}`, + data: { + Endpoint: api?.data?.Endpoint ?? '', + }, + waiting: !!api?.data?.Endpoint, + }) + + // Debounced search: update local input instantly for responsiveness, but + // defer the expensive global filter update so the browser doesn't hang. + const searchDebounceRef = useRef(null) + + const handleSearchChange = useCallback( + (event) => { + const value = event.target.value + setSearchValue(value) + + // Clear any pending debounce timer. + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + + // Defer the heavy table.setGlobalFilter call. + searchDebounceRef.current = setTimeout(() => { + table.setGlobalFilter(value) + }, 200) + }, + [table] + ) + + // Clean up debounce timer on unmount. + useEffect(() => { + return () => { + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + } + }, []) + + // Handle column filters toggle + const handleColumnFiltersToggle = () => { + const currentState = table.getState().showColumnFilters + table.setShowColumnFilters(!currentState) + } + + const resetToDefaultVisibility = () => { + setColumnVisibility((prevVisibility) => { + const updatedVisibility = {} + for (const col in prevVisibility) { + if (Array.isArray(originalSimpleColumns)) { + updatedVisibility[col] = originalSimpleColumns.includes(col) + } + } + return updatedVisibility + }) + if (pageName) { + settings.handleUpdate({ + columnDefaults: { + ...settings?.columnDefaults, + [pageName]: {}, + }, + }) + } + setColumnsAnchor(null) + } + + const resetToPreferedVisibility = () => { + if ( + settings?.columnDefaults?.[pageName] && + Object.keys(settings?.columnDefaults?.[pageName]).length > 0 + ) { + setColumnVisibility(settings?.columnDefaults?.[pageName]) + } else { + setColumnVisibility((prevVisibility) => { + const updatedVisibility = {} + for (const col in prevVisibility) { + if (Array.isArray(originalSimpleColumns)) { + updatedVisibility[col] = originalSimpleColumns.includes(col) + } + } + return updatedVisibility + }) + } + setColumnsAnchor(null) + } + + const saveAsPreferedColumns = () => { + if (pageName) { + settings.handleUpdate({ + columnDefaults: { + ...settings?.columnDefaults, + [pageName]: columnVisibility, + }, + }) + } + setColumnsAnchor(null) + } + + const mergeCaseInsensitive = (obj1, obj2) => { + const merged = { ...obj1 } + for (const key in obj2) { + const lowerCaseKey = key.toLowerCase() + const existingKey = Object.keys(merged).find( + (k) => k.toLowerCase() === lowerCaseKey + ) + if (existingKey) { + merged[existingKey] = obj2[key] + } else { + merged[key] = obj2[key] + } + } + return merged + } + + // Resolve variable placeholders in filter objects. + // Supported: {DaysAgo:N} → ISO date string N days in the past + const resolveFilterVariables = (obj) => { + if (!obj || typeof obj !== 'object') return obj + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => { + if (typeof v === 'string') { + const resolved = v.replace(/\{DaysAgo:(\d+)\}/g, (_, n) => { + const d = new Date() + d.setDate(d.getDate() - Number(n)) + return d.toISOString().split('T')[0] + }) + return [k, resolved] + } + return [k, v] + }) + ); + } + + // Show one column by name. The name comes from preset data, so it is added through + // Object.fromEntries (which defines the property directly) rather than a computed key + // in a spread literal, keeping a value such as '__proto__' away from the prototype chain. + const showColumn = (name) => { + const key = String(name).trim() + if (!usedColumns.includes(key)) return false + setColumnVisibility((prev) => Object.fromEntries([...Object.entries(prev), [key, true]])) + return true + } + + // Shared function for setting nested column visibility + const setNestedVisibility = (col) => { + if (typeof col === 'object' && col !== null) { + Object.keys(col).forEach((key) => { + if (showColumn(key)) { + setNestedVisibility(col[key]) + } + }) + } else { + showColumn(col) + } + } + + const normalizePersistedFilters = (last) => { + if (!last) { + return { graph: null, table: null } + } + if ('graph' in last || 'table' in last) { + const table = last.table ?? null + // new shape can carry the same non-string global garbage the legacy branch discards + if (table?.type === 'global' && typeof table.value !== 'string') { + return { graph: last.graph ?? null, table: null } + } + return { graph: last.graph ?? null, table } + } + // legacy single-slot {type, value, name} + if (last.type === 'graph') { + return { + graph: { id: last.name, name: last.name, value: last.value }, + table: null, + } + } + if ( + last.type === 'column' || + (last.type === 'global' && typeof last.value === 'string') + ) { + return { + graph: null, + table: { + id: last.name, + name: last.name, + type: last.type, + value: last.value, + }, + } + } + // reset marker or non-string global garbage + return { graph: null, table: null } + } + + const persistFilterSlots = (updater) => { + if (!pageName || !settings.persistFilters || !settings.setLastUsedFilter) { + return + } + const current = normalizePersistedFilters( + settings.lastUsedFilters?.[pageName] + ) + settings.setLastUsedFilter(pageName, updater(current)) + } + + // Columns any preset drives with a non-default `filterFn` (e.g. 'notEquals', 'notContains'), + // mapped to a plain 'contains' baseline. material-react-table's per-column filter mode is a + // sticky mutation on the column definition — switching it away from the default only takes + // when the new state.columnFilterFns entry is itself explicit, so clearing to `{}` after a + // preset like this leaves the override stuck. Passing this map on every column-preset click + // (falling back to 'contains' for any id not driven by the current preset) forces that + // mutation to reset every time, so manual searches on the column behave normally again once + // a different preset — or Reset — is applied. + const filterFnDefaults = useMemo(() => { + const ids = new Set() + filters.forEach((f) => { + if (Array.isArray(f?.value)) { + f.value.forEach((v) => { + if (v?.id && v?.filterFn) ids.add(v.id) + }) + } + }) + return Object.fromEntries([...ids].map((id) => [id, 'contains'])) + }, [filters]) + + const setTableFilter = (filter, filterType, filterName, presetId) => { + if (filterType === 'global' || filterType === undefined) { + if (activeFilters.table?.type === 'column') { + table.resetColumnFilters() + } + // The search box IS the global filter's visible form — a pending debounced + // keystroke or stale text would silently overwrite this preset otherwise. + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue(typeof filter === 'string' ? filter : '') + table.setGlobalFilter(filter) + setActiveFilters((prev) => ({ + ...prev, + table: { + id: presetId ?? filterName, + name: filterName, + type: 'global', + }, + })) + persistFilterSlots((cur) => ({ + ...cur, + table: { + id: presetId ?? filterName, + name: filterName, + type: 'global', + value: filter, + }, + })) + } + if (filterType === 'column') { + if (activeFilters.table?.type === 'global') { + table.resetGlobalFilter() + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue('') + } + if (viewMode !== 'cards') { + // Card view renders no header row for the filter inputs to appear in + table.setShowColumnFilters(true) + } + // A preset can request a non-default comparison (e.g. 'notEquals', 'notContains') + // for a column via `filterFn` on its value entry. Start from filterFnDefaults so + // every column any preset ever overrides gets an explicit entry here — falling + // back to 'contains' — then layer this preset's own overrides on top. + // Built via Map.set + Object.fromEntries rather than a bracket-assignment reduce: + // a preset entry's id ends up as a computed object key, and `acc[f.id] = ...` + // resolves to Object.prototype's __proto__ setter for that one key value — Map + // keys never touch the prototype chain, and Object.fromEntries defines properties + // directly rather than going through a property setter. + const filterFnOverrides = Array.isArray(filter) + ? Object.fromEntries( + filter.reduce((acc, f) => { + if (f?.id && f?.filterFn) acc.set(f.id, f.filterFn) + return acc + }, new Map()) + ) + : {} + table.setColumnFilterFns({ ...filterFnDefaults, ...filterFnOverrides }) + table.setColumnFilters(filter) + setActiveFilters((prev) => ({ + ...prev, + table: { + id: presetId ?? filterName, + name: filterName, + type: 'column', + }, + })) + persistFilterSlots((cur) => ({ + ...cur, + table: { + id: presetId ?? filterName, + name: filterName, + type: 'column', + value: filter, + }, + })) + } + if (filterType === 'reset') { + table.resetGlobalFilter() + table.resetColumnFilters() + table.setColumnFilterFns(filterFnDefaults) + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue('') + if (api?.data) { + setGraphFilterData({}) + resetToDefaultVisibility() + } + setCurrentEffectiveQueryKey(queryKey || title) // Reset to original query key + setActiveFilters({ graph: null, table: null }) // Clear active filters + if (settings.persistFilters && settings.setLastUsedFilter) { + settings.setLastUsedFilter(pageName, { graph: null, table: null }) + } + } + if (filterType === 'graph') { + const resolvedFilter = resolveFilterVariables(filter) + const filterProps = [ + '$filter', + '$select', + '$expand', + '$orderby', + '$count', + '$search', + 'ReverseTenantLookup', + 'ReverseTenantLookupProperty', + 'AsApp', + ] + const graphFilter = filterProps.reduce((acc, prop) => { + if (resolvedFilter[prop]) { + acc[prop] = resolvedFilter[prop] + } + return acc + }, {}) + //get api.data, merge with graphFilter, set api.data + const newQueryKey = `${queryKey ? queryKey : title}-${filterName}` + setGraphFilterData({ + data: { ...mergeCaseInsensitive(api.data, graphFilter) }, + queryKey: newQueryKey, + }) + setCurrentEffectiveQueryKey(newQueryKey) + setActiveFilters((prev) => ({ + ...prev, + graph: { id: presetId ?? filterName, name: filterName }, + })) // Track active graph filter + persistFilterSlots((cur) => ({ + ...cur, + graph: { + id: presetId ?? filterName, + name: filterName, + value: filter, + }, + })) + if (filter?.$select) { + let selectedColumns = [] + if (Array.isArray(filter?.$select)) { + selectedColumns = filter?.$select + } else if (typeof filter?.$select === 'string') { + selectedColumns = filter.$select.split(',') + } + if (selectedColumns.length > 0) { + setConfiguredSimpleColumns(selectedColumns) + selectedColumns.forEach((col) => { + setNestedVisibility(col) + }) + } + } + } + } + + const clearFilterSlot = (layer) => { + if (layer === 'graph') { + if (api?.data) { + setGraphFilterData({}) + resetToDefaultVisibility() + } + setCurrentEffectiveQueryKey(queryKey || title) + setConfiguredSimpleColumns(originalSimpleColumns) + } + if (layer === 'table') { + if (activeFilters.table?.type === 'global') { + table.resetGlobalFilter() + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue('') + } else { + table.resetColumnFilters() + } + } + persistFilterSlots((cur) => ({ ...cur, [layer]: null })) + setActiveFilters((prev) => ({ ...prev, [layer]: null })) + } + + const handlePresetClick = (filter) => { + const layer = filter.type === 'graph' ? 'graph' : 'table' + if (activeFilters[layer]?.id === presetKey(filter)) { + clearFilterSlot(layer) + } else { + setTableFilter(filter.value, filter.type, filter.filterName, filter.id) + } + } + + // Pages that compute `filters` asynchronously (or swap them per tenant) need the preset + // list to follow the prop — state-only init froze it at first render. Deep-equal via + // JSON: the prop is usually a fresh array literal every render. + const filtersJson = JSON.stringify(filters ?? []) + useEffect(() => { + const propFilters = JSON.parse(filtersJson) + setFilterList((prev) => { + const fetchedGraphPresets = (prev ?? []).filter( + (f) => + f.type === 'graph' && + !propFilters.some((p) => presetKey(p) === presetKey(f)) + ) + return [...propFilters, ...fetchedGraphPresets] + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filtersJson]) + + useEffect(() => { + if (api?.url === '/api/ListGraphRequest' && presetList.isSuccess) { + var endpoint = api?.data?.Endpoint?.replace(/^\//, '') + var graphPresetList = [] + GraphExplorerPresets.map((preset) => { + var presetEndpoint = preset?.params?.endpoint?.replace(/^\//, '') + if (presetEndpoint === endpoint) { + graphPresetList.push({ + id: preset?.id, + filterName: preset?.name, + value: preset?.params, + type: 'graph', + }) + } + }) + + presetList?.data?.Results?.map((preset) => { + var customPresetEndpoint = preset?.params?.endpoint?.replace( + /^\//, + '' + ) + if (customPresetEndpoint === endpoint) { + graphPresetList.push({ + id: preset?.id, + filterName: preset?.name, + value: preset?.params, + type: 'graph', + }) + } + }) + + // update filters to include graph explorer presets + setFilterList([...filters, ...graphPresetList]) + } + }, [presetList?.isSuccess, presetList?.data, simpleColumns]) + + const graphPresetItems = filterList?.filter((f) => f.type === 'graph') ?? [] + const tablePresetItems = filterList?.filter((f) => f.type !== 'graph') ?? [] + const showFilterSections = + graphPresetItems.length > 0 && tablePresetItems.length > 0 + const activeSlotCount = + (activeFilters.graph ? 1 : 0) + (activeFilters.table ? 1 : 0) + + const renderPresetItem = (filter, layer) => ( + { + setFiltersAnchor(null) + handlePresetClick(filter) + }} + > + + {activeFilters[layer]?.id === presetKey(filter) && ( + + )} + {filter.filterName} + + } + /> + + ) + + // count + button share this gate whether rendered inline or portaled into the header + const bulkActionsContent = ( + <> + {(table.getIsAllRowsSelected() || table.getIsSomeRowsSelected()) && ( + + {table.getSelectedRowModel().rows.length} rows selected + + )} + + {showBulkActionsButton && ( + + )} + + ) + + // feeds both CippMobileTableControls (cards) and CippTableFilterSheet (table branch) + const mobileColumnItems = table + .getAllColumns() + .filter((column) => !column.id.startsWith('mrt-')) + .map((column) => ({ + id: column.id, + visible: Boolean(column.getIsVisible()), + })) + const handleToggleColumn = (columnId, visible) => + setColumnVisibility({ ...columnVisibility, [columnId]: !visible }) + const handleExportCsvClick = () => + document.querySelector(`[data-csv-export="${title}"]`)?.click() + const handleExportPdfClick = () => + document.querySelector(`[data-pdf-export="${title}"]`)?.click() + const handleViewApiResponse = () => + isInDialog ? setJsonDialogOpen(true) : setOffcanvasVisible(true) + const handleEditGraphFilters = + api?.url === '/api/ListGraphRequest' ? () => setFilterCanvasVisible(true) : undefined + const handleResetFilters = () => setTableFilter('', 'reset', '') + const mobileIsRefreshing = Boolean( + getRequestData?.isFetching || refreshFunction?.isFetching + ) + + return ( + <> + {viewMode === 'cards' ? ( + + ) : undefined + } + dataSourceControls={dataSourceControls} + /> + ) : ( + <> + + {/* Left side - Main controls */} + + {/* phones refresh from the options sheet instead */} + {!mdDown && ( + + + + + {getRequestData?.isFetchNextPageError ? ( + + ) : ( + + )} + + + + + )} + + {/* Search Input */} + + + + + + {/* Desktop Buttons - always render for measurement, hide when in compact mode */} + {!mdDown && ( + + {/* Filters Button */} + } + endIcon={} + onClick={(event) => setFiltersAnchor(event.currentTarget)} + sx={{ + color: + activeSlotCount > 0 ? 'primary.main' : 'text.primary', + borderColor: + activeSlotCount > 0 ? 'primary.main' : undefined, + }} + > + {activeSlotCount > 0 + ? `Filters (${activeSlotCount})` + : 'Filters'} + + + {/* Columns Button */} + } + endIcon={} + onClick={(event) => setColumnsAnchor(event.currentTarget)} + > + Columns + + setColumnsAnchor(null)} + slotProps={{ + paper: { sx: { + mt: 1, + borderRadius: 2, + minWidth: 250, + maxHeight: 400, + } }, + }} + > + + + + + + + + + + + {table + .getAllColumns() + .filter((column) => !column.id.startsWith('mrt-')) + .map((column) => ( + + setColumnVisibility({ + ...columnVisibility, + [column.id]: !column.getIsVisible(), + }) + } + > + + + + ))} + + + {/* Export Button */} + {exportEnabled && ( + } + endIcon={} + onClick={(event) => setExportAnchor(event.currentTarget)} + > + Export + + )} + + )} + + {/* Compact Action Button — desktop compact mode only, the phone table uses the filter sheet */} + {!mdDown && useCompactMode && !hasSelection && ( + + setActionMenuAnchor(event.currentTarget)} + sx={{ flexShrink: 0 }} + > + + + + )} + + {/* phones keep the kebab open regardless of selection, the only route to the + sheet (refresh, export, rows-per-page) down there, not just filters */} + {(mdDown || (useCompactMode && !hasSelection)) && ( + { + if (mdDown) { + setMobileFilterSheetOpen(true) + return + } + setFiltersAnchor(event.currentTarget) + }} + sx={{ + flexShrink: 0, + ...(mdDown && activeSlotCount > 0 && { color: 'primary.main' }), + }} + > + {mdDown ? ( + + + + ) : ( + + )} + + )} + + {/* way back to cards, far right to match the card bar's toggle position */} + {tableViewActive && showReturnToCards && ( + + + + {/* destination icon: tapping here returns to cards */} + + + + + )} + + {/* Compact Action Menu — desktop compact mode only */} + setActionMenuAnchor(null)} + slotProps={{ + paper: { sx: { + mt: 1, + borderRadius: 2, + minWidth: 180, + } }, + }} + > + {/* Anchor the nested menus to the stable overflow IconButton — anchoring to + event.currentTarget here targets a MenuItem inside a menu that closes in + the same tick, which positions the next popover unpredictably. */} + { + setColumnsAnchor(actionMenuAnchor) + setActionMenuAnchor(null) + }} + > + + + + Columns + + {exportEnabled && ( + { + setExportAnchor(actionMenuAnchor) + setActionMenuAnchor(null) + }} + > + + + + Export + + )} + { + table.setIsFullScreen(!table.getState().isFullScreen) + setActionMenuAnchor(null) + }} + > + + + + + {table.getState().isFullScreen + ? 'Exit Fullscreen' + : 'Fullscreen'} + + + + + {/* Filters Menu */} + setFiltersAnchor(null)} + slotProps={{ + paper: { sx: { + mt: 1, + borderRadius: 2, + minWidth: 200, + } }, + }} + > + { + handleColumnFiltersToggle() + setFiltersAnchor(null) + }} + > + + {table.getState().showColumnFilters + ? 'Hide Column Filters' + : 'Show Column Filters'} + + + + { + setTableFilter('', 'reset', '') + setFiltersAnchor(null) + }} + > + + + {api?.url === '/api/ListGraphRequest' && ( + { + setFiltersAnchor(null) + setFilterCanvasVisible(true) + }} + > + + + )} + {showFilterSections && ( + Graph filters + )} + {!showFilterSections && graphPresetItems.length > 0 && ( + + )} + {graphPresetItems.map((filter) => + renderPresetItem(filter, 'graph') + )} + {showFilterSections && ( + Table filters + )} + {!showFilterSections && tablePresetItems.length > 0 && ( + + )} + {tablePresetItems.map((filter) => + renderPresetItem(filter, 'table') + )} + + + {/* Columns Menu */} + setColumnsAnchor(null)} + slotProps={{ + paper: { sx: { + mt: 1, + borderRadius: 2, + minWidth: 250, + maxHeight: 400, + } }, + }} + > + + + + + + + + + + + {table + .getAllColumns() + .filter((column) => !column.id.startsWith('mrt-')) + .map((column) => ( + + setColumnVisibility({ + ...columnVisibility, + [column.id]: !column.getIsVisible(), + }) + } + > + + + + ))} + + + {/* Export Menu */} + {exportEnabled && ( + setExportAnchor(null)} + slotProps={{ + paper: { sx: { + mt: 1, + borderRadius: 2, + minWidth: 180, + } }, + }} + > + { + // Trigger CSV export + const csvButton = document.querySelector( + `[data-csv-export="${title}"]` + ) + if (csvButton) csvButton.click() + setExportAnchor(null) + }} + > + + + + + + { + // Trigger PDF export + const pdfButton = document.querySelector( + `[data-pdf-export="${title}"]` + ) + if (pdfButton) pdfButton.click() + setExportAnchor(null) + }} + > + + + + + + {builtInBulkExportAvailable && ( + <> + + { + handleExportSelectedToCsv() + setExportAnchor(null) + }} + > + + + + + + { + handleExportSelectedToPdf() + setExportAnchor(null) + }} + > + + + + + + + )} + + { + if (isInDialog) { + setJsonDialogOpen(true) + } else { + setOffcanvasVisible(true) + } + setExportAnchor(null) + }} + > + + + + + + + )} + + + {/* Right side - Additional controls */} + + {/* Selected rows indicator + Bulk Actions - inline, unless portaled into the header */} + {!bulkActionsSlot && bulkActionsContent} + + {/* Queue tracker */} + + + + + table.setPageSize(size)} + pageSizeOptions={[25, 50, 100, 250, 500]} + dataSourceControls={dataSourceControls} + /> + + )} + + {bulkActionsSlot && createPortal(bulkActionsContent, bulkActionsSlot)} + + {/* Hidden export buttons for triggering — outside the mode branch so the + mobile filter sheet's export items can click them too */} + + + + + + {/* Bulk Actions Menu - now inline with toolbar */} + + {actions && + customBulkActions.map((action, index) => ( + handleBulkAction(action, popover.handleClose)} + > + + {action.icon} + + {action.label} + + ))} + + + {/* API Response Off-Canvas - only show when not in dialog mode */} + {!isInDialog && ( + { + setOffcanvasVisible(false) + }} + > + + + + + )} + + {/* Action Dialog */} + {actionData.ready && ( + + )} + + {/* Graph Filter Off-Canvas */} + setFilterCanvasVisible(!filterCanvasVisible)} + contentPadding={1} + keepMounted={true} + aboveModal={isInDialog} + > + presetKey(f) === activeFilters.graph.id + ) ?? null) + : null + } + onPresetSelect={(preset) => { + if (preset?.value && preset?.type === 'graph') { + setTableFilter( + preset.value, + preset.type, + preset.filterName, + preset.id + ) + } + }} + onSubmitFilter={(filter) => { + setTableFilter(filter, 'graph', 'Custom Filter') + setFilterCanvasVisible(false) + if (filter?.$select) { + let selectedColumns = [] + if (Array.isArray(filter?.$select)) { + selectedColumns = filter?.$select + } else if (typeof filter?.$select === 'string') { + selectedColumns = filter.$select.split(',') + } + if (selectedColumns.length > 0) { + setConfiguredSimpleColumns(selectedColumns) + selectedColumns.forEach((col) => { + setNestedVisibility(col) + }) + } + } else { + setConfiguredSimpleColumns(originalSimpleColumns) + } + }} + component="card" + /> + + + {/* JSON Dialog for when in dialog mode */} + {isInDialog && ( + setJsonDialogOpen(false)} + sx={{ zIndex: (theme) => theme.zIndex.modal + 1 }} + > + API Response + + + + + + + + )} + + ); + } +) + +CIPPTableToptoolbar.displayName = 'CIPPTableToptoolbar' diff --git a/src/components/CippTable/CippCellText.jsx b/src/components/CippTable/CippCellText.jsx new file mode 100644 index 000000000000..b4f5bccdcdcf --- /dev/null +++ b/src/components/CippTable/CippCellText.jsx @@ -0,0 +1,82 @@ +import { Box, Tooltip } from '@mui/material' +import { forwardRef, useCallback, useRef, useState } from 'react' + +const ELLIPSIS_SX = { + cursor: 'text', + userSelect: 'text', + display: 'block', + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} + +export const CippCellText = forwardRef(function CippCellText(props, ref) { + const { children, sx, onMouseEnter, onMouseLeave, ...rest } = props + const localRef = useRef(null) + const [tooltipOpen, setTooltipOpen] = useState(false) + + const setRefs = useCallback( + (node) => { + localRef.current = node + if (typeof ref === 'function') { + ref(node) + } else if (ref) { + ref.current = node + } + }, + [ref] + ) + + const handleMouseEnter = (event) => { + const el = localRef.current + if (el && el.scrollWidth > el.clientWidth) { + setTooltipOpen(true) + } + onMouseEnter?.(event) + } + + const handleMouseLeave = (event) => { + setTooltipOpen(false) + onMouseLeave?.(event) + } + + const title = children == null ? '' : String(children) + + return ( + setTooltipOpen(false)} + disableHoverListener + placement="top" + arrow + disableInteractive + > + + {children} + + + ) +}) + +export const formatCellText = (value, isText) => { + if (isText) { + return value == null ? '' : String(value) + } + if (value == null || value === '') { + return value + } + if (typeof value === 'string' || typeof value === 'number') { + return {value} + } + return value +} diff --git a/src/components/CippTable/CippDataTable.js b/src/components/CippTable/CippDataTable.js deleted file mode 100644 index 81decf73bba0..000000000000 --- a/src/components/CippTable/CippDataTable.js +++ /dev/null @@ -1,1764 +0,0 @@ -import { MaterialReactTable, useMaterialReactTable } from 'material-react-table' -import { - Card, - CardContent, - CardHeader, - Divider, - ListItemIcon, - ListItemText, - MenuItem, - SvgIcon, - Typography, -} from '@mui/material' -import { ResourceUnavailable } from '../resource-unavailable' -import { ResourceError } from '../resource-error' -import { Scrollbar } from '../scrollbar' -import { useCallback, useEffect, useMemo, useState, useRef } from 'react' -import { useRouter } from 'next/router' -import { ApiGetCallWithPagination } from '../../api/ApiCall' -import { utilTableMode } from './util-tablemode' -import { - utilColumnsFromAPI, - resolveSimpleColumnVariables, -} from './util-columnsFromAPI' -import { CIPPTableToptoolbar } from './CIPPTableToptoolbar' -import { Info, More, MoreHoriz } from '@mui/icons-material' -import { CippOffCanvas } from '../CippComponents/CippOffCanvas' -import { useDialog } from '../../hooks/use-dialog' -import { CippApiDialog } from '../CippComponents/CippApiDialog' -import { getCippError } from '../../utils/get-cipp-error' -import { Box, Stack } from '@mui/system' -import { useSettings } from '../../hooks/use-settings' -import { parseCippDate } from '../../utils/parse-cipp-date' -import { isEqual } from 'lodash' // Import lodash for deep comparison -import { useLicenseBackfill } from '../../hooks/use-license-backfill' -import { useTableViewMode, useIsNarrowForTables } from '../../hooks/use-breakpoint' -import { CippMobileCardList } from './CippMobileCardList' -import { CippPageActionsFab } from '../CippComponents/CippPageActionsFab' -import CippDataTableButton from './CippDataTableButton' -import { CippTableCardButton } from './CippTableCardButton' -import { - subTableIsSelected, - subTableShowsCachedColumn, - resolveSubTableSimpleColumns, - getSubTableDisplayColumnIds, - columnOrderHasStaleIds, -} from './util-subTables' -import { attachParentRow, getRowTenant } from '../../utils/resolve-row-templates' - -// Resolve dot-delimited property paths against arbitrary data objects. -const getNestedValue = (source, path) => { - if (!source) { - return undefined - } - if (!path) { - return source - } - - return path.split('.').reduce((acc, key) => { - if (acc === undefined || acc === null) { - return undefined - } - if (typeof acc !== 'object') { - return undefined - } - return acc[key] - }, source) -} - -// Resolve dot-delimited column ids against the original row data so nested fields can sort/filter properly. -const getRowValueByColumnId = (row, columnId) => { - if (!row?.original || !columnId) { - return undefined - } - - if (columnId.includes('@odata')) { - return row.original[columnId] - } - - return getNestedValue(row.original, columnId) -} - -const compareNullable = (aVal, bVal) => { - if (aVal === null && bVal === null) { - return 0 - } - if (aVal === null) { - return 1 - } - if (bVal === null) { - return -1 - } - if (aVal === bVal) { - return 0 - } - return aVal > bVal ? 1 : -1 -} - -// walk up from the card surface to the page's scrolling ancestor (LayoutContainer, -// overflowY auto) and align the surface with its top. the table flips in at the same -// page slot, so the height measurement taken right after reads a deterministic top -// and pages with content above the table keep the table in view -const scrollNodeToScrollableAncestorTop = (node) => { - if (!node) { - return - } - let ancestor = node.parentElement - while (ancestor && ancestor !== document.body) { - const overflowY = window.getComputedStyle(ancestor).overflowY - if (overflowY === 'auto' || overflowY === 'scroll') { - ancestor.scrollTop += node.getBoundingClientRect().top - ancestor.getBoundingClientRect().top - return - } - ancestor = ancestor.parentElement - } - window.scrollTo(0, window.scrollY + node.getBoundingClientRect().top) -} - -/** - * Column order for a user-curated selection: the selected ids first, in selection order, - * then everything else in its existing order. Exported for tests. - * - * MRT only reads initialState.columnOrder once, so when the graph filter swaps in a new - * \$select list after mount, the new columns (dot-delimited nested fields included) were - * appended last — and the card view's three detail slots are filled in column order, so a - * field the user explicitly selected was exactly the one that overflowed into "+N more". - */ -export const orderColumnsBySelection = (allIds, selectedIds) => { - const selected = selectedIds.filter((id) => allIds.includes(id)) - const rest = allIds.filter((id) => !selected.includes(id)) - return [...selected, ...rest] -} - -// ── Module-level constants ────────────────────────────────────────────────── -// These never change between renders, so extracting them avoids creating new -// object references on every render cycle. - -// Stable ref so an undefined `data` prop doesn't create a fresh [] each render -// and loop the static-data sync effect. -const EMPTY_ARRAY = [] - -const buildSubTableColumn = (sub) => ({ - id: sub.id, - header: sub.header ?? sub.id, - size: sub.size ?? 120, - minSize: sub.minSize ?? 100, - enableSorting: false, - enableColumnFilter: false, - enableGlobalFilter: false, - accessorFn: (row) => { - if (typeof sub.label === 'function') { - return sub.label(row) - } - return sub.label ?? 'View' - }, - Cell: ({ row }) => ( - - ), -}) - -const SORTING_FNS = { - dateTimeNullsLast: (a, b, id) => { - const aRaw = getRowValueByColumnId(a, id) - const bRaw = getRowValueByColumnId(b, id) - const aDate = aRaw ? parseCippDate(aRaw) : null - const bDate = bRaw ? parseCippDate(bRaw) : null - const aTime = - aDate && !Number.isNaN(aDate.getTime()) ? aDate.getTime() : null - const bTime = - bDate && !Number.isNaN(bDate.getTime()) ? bDate.getTime() : null - - return compareNullable(aTime, bTime) - }, - number: (a, b, id) => { - const aRaw = getRowValueByColumnId(a, id) - const bRaw = getRowValueByColumnId(b, id) - const aNum = typeof aRaw === 'number' ? aRaw : Number(aRaw) - const bNum = typeof bRaw === 'number' ? bRaw : Number(bRaw) - const aVal = Number.isNaN(aNum) ? null : aNum - const bVal = Number.isNaN(bNum) ? null : bNum - - return compareNullable(aVal, bVal) - }, - boolean: (a, b, id) => { - const aRaw = getRowValueByColumnId(a, id) - const bRaw = getRowValueByColumnId(b, id) - const toBool = (value) => { - if (value === null || value === undefined) { - return null - } - if (typeof value === 'boolean') { - return value - } - if (typeof value === 'string') { - const lower = value.toLowerCase() - if (lower === 'true' || lower === 'yes') { - return true - } - if (lower === 'false' || lower === 'no') { - return false - } - } - if (typeof value === 'number') { - return value !== 0 - } - return null - } - - const aBool = toBool(aRaw) - const bBool = toBool(bRaw) - const aNumeric = aBool === null ? null : aBool ? 1 : 0 - const bNumeric = bBool === null ? null : bBool ? 1 : 0 - - return compareNullable(aNumeric, bNumeric) - }, -} - -const FILTER_FNS = { - notContains: (row, columnId, value) => { - const rowValue = row.getValue(columnId) - if (rowValue === null || rowValue === undefined) { - return false - } - - const stringValue = String(rowValue) - if ( - stringValue.includes('[object Object]') || - !stringValue.toLowerCase().includes(value.toLowerCase()) - ) { - return true - } else { - return false - } - }, - regex: (row, columnId, value) => { - try { - const regex = new RegExp(value, 'i') - const rowValue = row.getValue(columnId) - if ( - typeof rowValue === 'string' && - !rowValue.includes('[object Object]') - ) { - return regex.test(rowValue) - } - return false - } catch (error) { - // If regex is invalid, don't filter - return true - } - }, -} - -const MUI_TABLE_HEAD_CELL_PROPS = { - sx: { - '& .MuiTableCell-root': { - padding: '8px 16px', - }, - '& .MuiAutocomplete-root': { - width: '100%', - }, - '& .MuiAutocomplete-root .MuiInputBase-root': { - height: '40px !important', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - display: 'flex', - flexWrap: 'nowrap', - }, - '& .MuiAutocomplete-root .MuiInputBase-root .MuiInputBase-input': { - height: '24px', - minHeight: '24px', - maxHeight: '24px', - }, - '& .MuiInputBase-root': { - height: '40px !important', - }, - '& .MuiInputBase-input': { - height: '24px', - minHeight: '24px', - maxHeight: '24px', - }, - '& .MuiChip-label.MuiChip-labelMedium': { - maxWidth: '80px', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - padding: '0 4px', - }, - '& .MuiChip-root': { - height: '24px', - maxHeight: '24px', - '&::before': { - content: 'attr(data-label)', - display: 'none', - }, - '&:hover::before': { - display: 'block', - position: 'absolute', - top: '-25px', - left: '0', - backgroundColor: 'rgba(0, 0, 0, 0.8)', - color: 'white', - padding: '4px 8px', - borderRadius: '4px', - fontSize: '12px', - whiteSpace: 'nowrap', - zIndex: 9999, - }, - }, - }, -} - -const MUI_TABLE_BODY_CELL_ON_COPY = (e) => { - const sel = window.getSelection()?.toString() ?? '' - if (sel) { - e.preventDefault() - e.stopPropagation() - e.nativeEvent?.stopImmediatePropagation?.() - e.clipboardData.setData('text/plain', sel) - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(sel).catch(() => {}) - } - } -} - -const MUI_TABLE_BODY_CELL_PROPS = { onCopy: MUI_TABLE_BODY_CELL_ON_COPY } - -const MRT_THEME = (theme) => ({ - baseBackgroundColor: theme.palette.background.paper, -}) - -// Compute a lightweight "schema key" from data to decide whether columns need recomputing. -// Only looks at the keys of the first few rows rather than deep-comparing the full dataset. -const computeSchemaKey = (data) => { - if (!Array.isArray(data) || data.length === 0) return '' - const sample = data.slice(0, 3) - const keys = new Set() - for (const row of sample) { - if (row && typeof row === 'object') { - for (const k of Object.keys(row)) keys.add(k) - } - } - return [...keys].sort().join(',') + '|' + data.length -} - -// ── Module-level render helpers for filter mode menus ────────────────────── -function renderGlobalFilterModeMenuItemsFn({ - internalFilterOptions, - onSelectFilterMode, -}) { - const customFilterOptions = [ - { - option: 'regex', - label: 'Regex', - symbol: '(.*)', - }, - ] - - customFilterOptions.forEach((filterOption) => { - if ( - !internalFilterOptions.some( - (option) => option.option === filterOption.option - ) - ) { - internalFilterOptions.push(filterOption) - } - }) - - return internalFilterOptions.map((filterOption) => ( - onSelectFilterMode(filterOption.option)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: '0.5rem', - }} - > - - {filterOption.symbol} - - {filterOption.label} - - )) -} - -function renderColumnFilterModeMenuItemsFn({ - internalFilterOptions, - onSelectFilterMode, -}) { - const customFilterOptions = [ - { - option: 'notContains', - label: 'Not Contains', - symbol: '!*', - }, - { - option: 'regex', - label: 'Regex', - symbol: '(.*)', - }, - ] - - const combinedFilterOptions = [ - ...internalFilterOptions, - ...customFilterOptions, - ] - - return combinedFilterOptions.map((filterOption) => ( - onSelectFilterMode(filterOption.option)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: '0.5rem', - }} - > - - {filterOption.symbol} - - {filterOption.label} - - )) -} - -export const CippDataTable = (props) => { - const { - queryKey, - data = EMPTY_ARRAY, - columns = [], - api = {}, - isFetching = false, - columnVisibility: initialColumnVisibility = { - id: false, - RowKey: false, - ETag: false, - PartitionKey: false, - Timestamp: false, - TableTimestamp: false, - }, - exportEnabled = true, - simpleColumns = [], - dataFilter, - actions, - title = 'Report', - simple = false, - cardButton, - offCanvas = false, - offCanvasOnRowClick = false, - noCard = false, - hideTitle = false, - refreshFunction, - incorrectDataMessage = 'Data not in correct format', - onChange, - filters, - maxHeightOffset = '380px', - defaultSorting = [], - isInDialog = false, - showBulkExportAction = true, - viewMode: viewModeProp, - mobileCard, - dataSourceControls, - subTables = EMPTY_ARRAY, - persistenceKey, - parentRow, - } = props - - // Create a map of column IDs to their filterType for quick lookup - const filterTypeMap = useMemo(() => { - if (!filters || !Array.isArray(filters)) return {} - return filters.reduce((acc, filter) => { - if (filter.value && Array.isArray(filter.value)) { - filter.value.forEach((v) => { - if (v.id && filter.filterType) { - acc[v.id] = filter.filterType - } - }) - } - return acc - }, {}) - }, [filters]) - - // Track if initial filters have been applied - const filtersInitializedRef = useRef(false) - const previousFiltersRef = useRef(null) - - const [columnVisibility, setColumnVisibility] = useState( - initialColumnVisibility - ) - const [configuredSimpleColumns, setConfiguredSimpleColumns] = - useState(simpleColumns) - const [usedData, setUsedData] = useState(data) - const [usedColumns, setUsedColumns] = useState([]) - const lastOrderedSelectionRef = useRef(null) - const [offcanvasVisible, setOffcanvasVisible] = useState(false) - const [offCanvasData, setOffCanvasData] = useState({}) - const [offCanvasRowIndex, setOffCanvasRowIndex] = useState(0) - const [customComponentData, setCustomComponentData] = useState({}) - const [customComponentVisible, setCustomComponentVisible] = useState(false) - const [actionData, setActionData] = useState({ - data: {}, - action: {}, - ready: false, - }) - const [graphFilterData, setGraphFilterData] = useState({}) - const [sorting, setSorting] = useState([]) - const [columnFilters, setColumnFilters] = useState([]) - const waitingBool = api?.url ? true : false - - // The cards branch and the renderTopToolbar branch are two alternating CIPPTableToptoolbar - // instances (only one is ever mounted), so state that must survive the cards<->table flip - // lives here and is passed down as props to both. - const [activeFilters, setActiveFilters] = useState({ graph: null, table: null }) - const [searchValue, setSearchValue] = useState('') - const restoredFiltersRef = useRef(new Set()) - - const settings = useSettings() - const router = useRouter() - const routerPageName = router.pathname.split('/').slice(1).join('/') - const pageName = persistenceKey ?? (isInDialog ? '' : routerPageName) - - // 'cards' below the md breakpoint (or when forced via settings/prop), 'table' otherwise. - // simple tables always resolve to 'table'. - const resolvedViewMode = useTableViewMode({ viewMode: viewModeProp, simple }) - // same pivot as the cards/table auto mode, so the FAB and the card list agree on width - const isNarrowViewport = useIsNarrowForTables() - // viewMode prop or simple is a hard force, the toggle never overrides it - const toggleAllowed = !viewModeProp && !simple - // Mobile select mode: checkboxes on cards + the bottom bulk bar. Lives here so the - // toolbar (which renders the Select toggle) and the card list stay in sync. Picker - // tables (onChange) force it on — selection is their entire purpose. - const [mobileSelectMode, setMobileSelectMode] = useState(false) - // portal target for the header's bulk-actions slot, set by the CardHeader's ref callback - const [headerBulkSlot, setHeaderBulkSlot] = useState(null) - - // transient cards<->table override, session-only, never persisted - const [viewOverride, setViewOverride] = useState(null) - const effectiveViewMode = toggleAllowed ? (viewOverride ?? resolvedViewMode) : resolvedViewMode - const isCardView = effectiveViewMode === 'cards' - const tableViewActive = effectiveViewMode === 'table' - const CardViewSurface = noCard ? Box : Card - const [narrowTableMaxHeight, setNarrowTableMaxHeight] = useState(null) - // way back button: table is only up because of the override, phone default is still cards - const showReturnToCards = toggleAllowed && tableViewActive && resolvedViewMode === 'cards' - - // Hook to trigger re-render when license backfill completes - const { updateTrigger } = useLicenseBackfill() - - // Ref to track previous schema key so we only recompute columns when the data shape changes. - const prevSchemaKeyRef = useRef('') - // Ref to track previous data reference for the static-data sync effect. - const prevDataRef = useRef(data) - - const getRequestData = ApiGetCallWithPagination({ - url: api.url, - data: { ...api.data }, - queryKey: queryKey ? queryKey : title, - waiting: waitingBool, - ...graphFilterData, - }) - - useEffect(() => { - // Only set initial filters if they haven't been set yet OR if the filters prop has actually changed - const filtersChanged = !isEqual(filters, previousFiltersRef.current) - - if ( - filters && - Array.isArray(filters) && - filters.length > 0 && - (!filtersInitializedRef.current || filtersChanged) - ) { - // Only auto-apply filters that are in MRT column-filter format ({ id, value }). - // Preset objects ({ filterName, value, type }) are toolbar buttons only — applying them - // directly sets id=undefined which causes MRT to throw. - const columnFormatFilters = filters.filter((f) => f.id !== undefined) - - if (columnFormatFilters.length > 0) { - const processedFilters = columnFormatFilters.map((filter) => { - if (filter.filterType === 'equal') { - return { - ...filter, - value: Array.isArray(filter.value) - ? filter.value - : [filter.value], - } - } - return filter - }) - setColumnFilters(processedFilters) - } - - filtersInitializedRef.current = true - previousFiltersRef.current = filters - } - }, [filters]) - - // Sync static data prop into usedData (only when not using API). - // We avoid including usedData in the dependency array to prevent the expensive - // isEqual check from running on every API-driven data change. - useEffect(() => { - if (Array.isArray(data) && !api?.url) { - // Only update if the data prop reference actually changed. - if (data !== prevDataRef.current) { - prevDataRef.current = data - setUsedData(data) - } - } - }, [data, api?.url]) - - useEffect(() => { - if (getRequestData.isSuccess && !getRequestData.isFetching) { - const lastPage = - getRequestData.data?.pages[getRequestData.data.pages.length - 1] - const nextLinkExists = lastPage?.Metadata?.nextLink - if (nextLinkExists) { - getRequestData.fetchNextPage() - } - } - }, [getRequestData.data?.pages?.length, getRequestData.isFetching, queryKey]) - - useEffect(() => { - if (getRequestData.isSuccess) { - const allPages = getRequestData.data.pages - - const combinedResults = allPages.flatMap((page) => { - const nestedData = getNestedValue(page, api.dataKey) - return nestedData !== undefined ? nestedData : [] - }) - setUsedData( - dataFilter ? combinedResults.filter(dataFilter) : combinedResults - ) - } - }, [ - getRequestData.isSuccess, - getRequestData.data, - api.dataKey, - getRequestData.isFetching, - queryKey, - ]) - - // Derive columns from data — only when the data schema actually changes. - useEffect(() => { - if ( - !Array.isArray(usedData) || - usedData.length === 0 || - typeof usedData[0] !== 'object' || - usedData === null || - usedData === undefined - ) { - return - } - - const schemaKey = computeSchemaKey(usedData) - // Skip expensive column generation if the schema hasn't changed. - if (schemaKey === prevSchemaKeyRef.current && usedColumns.length > 0) { - return - } - prevSchemaKeyRef.current = schemaKey - - const apiColumns = utilColumnsFromAPI(usedData) - - // Apply custom filterFn to columns that have filterType === 'equal' - const enhancedApiColumns = apiColumns.map((col) => { - if (filterTypeMap[col.id] === 'equal') { - return { - ...col, - filterFn: 'equals', - } - } - return col - }) - - let finalColumns = [] - let newVisibility = { ...columnVisibility } - - // Check if we're in AllTenants mode and data has Tenant property - const isAllTenants = settings?.currentTenant === 'AllTenants' - const hasTenantProperty = usedData.some( - (row) => row && typeof row === 'object' && 'Tenant' in row - ) - const shouldShowTenant = isAllTenants && hasTenantProperty - - if (columns.length === 0 && configuredSimpleColumns.length === 0) { - finalColumns = enhancedApiColumns - enhancedApiColumns.forEach((col) => { - newVisibility[col.id] = true - }) - } else if (configuredSimpleColumns.length > 0) { - // Resolve any variables in the simple columns before checking visibility - const resolvedSimpleColumns = resolveSubTableSimpleColumns( - resolveSimpleColumnVariables( - configuredSimpleColumns, - usedData - ), - subTables, - usedData - ) - - // Add Tenant to resolved columns if in AllTenants mode and not already included - let finalResolvedColumns = [...resolvedSimpleColumns] - if (shouldShowTenant && !resolvedSimpleColumns.includes('Tenant')) { - finalResolvedColumns = [...resolvedSimpleColumns, 'Tenant'] - } - - finalColumns = enhancedApiColumns - finalColumns.forEach((col) => { - if (col.id !== undefined) { - newVisibility[col.id] = finalResolvedColumns.includes(col.id) - } - }) - // Selection order wins over data-key order — but only when the resolved selection - // changed (including subTable cachedColumn swaps), so a data refetch doesn't - // stomp a manual column reorder. - const resolvedOrderKey = finalResolvedColumns.join('|') - if (lastOrderedSelectionRef.current !== resolvedOrderKey) { - lastOrderedSelectionRef.current = resolvedOrderKey - const subTableIds = getSubTableDisplayColumnIds( - subTables, - configuredSimpleColumns, - usedData - ) - const allIds = [ - ...new Set([ - ...finalColumns.map((col) => col.id).filter(Boolean), - ...subTableIds, - ]), - ] - table.setColumnOrder(orderColumnsBySelection(allIds, finalResolvedColumns)) - } - } else { - const providedColumnKeys = new Set( - columns.map((col) => col.id || col.header) - ) - finalColumns = [ - ...columns, - ...enhancedApiColumns.filter((col) => !providedColumnKeys.has(col.id)), - ] - finalColumns.forEach((col) => { - const key = col.id ?? col.accessorKey - if (key !== undefined) { - newVisibility[key] = providedColumnKeys.has(col.id) - } - }) - - // Handle Tenant column for custom columns case - if (shouldShowTenant) { - const tenantColumn = finalColumns.find((col) => col.id === 'Tenant') - if (tenantColumn) { - // Make tenant visible - newVisibility['Tenant'] = true - } - } - } - if (defaultSorting?.length > 0) { - setSorting(defaultSorting) - } - setUsedColumns(finalColumns) - setColumnVisibility(newVisibility) - }, [ - columns.length, - usedData, - queryKey, - settings?.currentTenant, - filterTypeMap, - subTables, - ]) - - // Previous-value refs for the guards below: CippDataTable is the single owner of this - // state across both toolbar instances, so an effect can compare against the last value - // it actually saw rather than firing unconditionally on every render. - const prevTenantRef = useRef(settings?.currentTenant) - const prevQueryKeyRef = useRef(queryKey || title) - const prevPageNameRef = useRef(pageName) - const appliedColumnDefaultsRef = useRef({}) - - // if the currentTenant switches, remove graph filters and the active-filter highlight - useEffect(() => { - const currentTenant = settings?.currentTenant - if (prevTenantRef.current === currentTenant) { - return - } - prevTenantRef.current = currentTenant - if (currentTenant) { - setGraphFilterData({}) - setActiveFilters({ graph: null, table: null }) - restoredFiltersRef.current.delete(`${pageName}-graph`) - } - }, [settings?.currentTenant, pageName]) - - // clear the active-filter highlight when the effective query key changes (tenant swap, - // different queryKey/title) - useEffect(() => { - const effectiveKey = queryKey || title - if (prevQueryKeyRef.current === effectiveKey) { - return - } - prevQueryKeyRef.current = effectiveKey - setActiveFilters({ graph: null, table: null }) - }, [queryKey, title]) - - // clear persisted-filter restoration tracking only when the page actually changes - useEffect(() => { - if (prevPageNameRef.current === pageName) { - return - } - prevPageNameRef.current = pageName - restoredFiltersRef.current.clear() - }, [pageName]) - - // apply preferred columns once per page, and again whenever the saved preference's - // identity changes. Nested dialog tables must not read or write the parent page key. - useEffect(() => { - if (!pageName) { - return - } - const preferred = settings?.columnDefaults?.[pageName] - if ( - preferred && - Object.keys(preferred).length > 0 && - appliedColumnDefaultsRef.current[pageName] !== preferred - ) { - appliedColumnDefaultsRef.current[pageName] = preferred - setColumnVisibility(preferred) - } - }, [settings?.columnDefaults?.[pageName], pageName]) - - const createDialog = useDialog() - const hasActions = !!actions - const hasOffCanvas = !!offCanvas - const hasOnChange = !!onChange - - // Compute modeInfo via useMemo so it stays stable but updates when relevant inputs change. - const modeInfo = useMemo( - () => - utilTableMode( - columnVisibility, - simple, - actions, - configuredSimpleColumns, - offCanvas, - onChange, - maxHeightOffset, - settings, - effectiveViewMode, - isNarrowViewport - ), - [ - simple, - hasActions, - hasOffCanvas, - hasOnChange, - maxHeightOffset, - settings?.tablePageSize?.value, - effectiveViewMode, - isNarrowViewport, - ] - ) - - // Include updateTrigger in data memo to force re-render when license backfill completes. - // Also refresh data identity when derived columns change so TanStack re-runs filtering - // for searches entered before columns are available. - const memoizedData = useMemo( - () => (Array.isArray(usedData) ? usedData.slice() : usedData), - [usedData, updateTrigger, usedColumns] - ) - - // Sanitize columnVisibility to remove any undefined/invalid keys before passing to MRT - const sanitizedColumnVisibility = useMemo(() => { - const result = {} - for (const key of Object.keys(columnVisibility)) { - if (key !== 'undefined' && key !== undefined) - result[key] = columnVisibility[key] - } - return result - }, [columnVisibility]) - - const displayColumns = useMemo(() => { - if (!Array.isArray(subTables) || subTables.length === 0) { - return usedColumns - } - const cachedHeaders = new Map() - const injected = [] - for (const sub of subTables) { - if (!subTableIsSelected(sub, configuredSimpleColumns)) { - continue - } - if (subTableShowsCachedColumn(sub, usedData)) { - cachedHeaders.set(sub.cachedColumn, sub.header ?? sub.id) - continue - } - injected.push(buildSubTableColumn(sub)) - } - const columns = cachedHeaders.size - ? usedColumns.map((col) => - cachedHeaders.has(col.id) - ? { ...col, header: cachedHeaders.get(col.id) } - : col - ) - : usedColumns - const injectedById = new Map(injected.map((col) => [col.id, col])) - const replaced = columns.map((col) => injectedById.get(col.id) ?? col) - const existing = new Set(columns.map((col) => col.id)) - return [...replaced, ...injected.filter((col) => !existing.has(col.id))] - }, [usedColumns, usedData, subTables, configuredSimpleColumns]) - - useEffect(() => { - if (!Array.isArray(subTables) || subTables.length === 0) { - return - } - setColumnVisibility((prev) => { - const next = { ...prev } - let changed = false - for (const sub of subTables) { - if (!subTableIsSelected(sub, configuredSimpleColumns)) { - continue - } - const columnId = subTableShowsCachedColumn(sub, usedData) - ? sub.cachedColumn - : sub.id - if (next[columnId] === undefined) { - next[columnId] = true - changed = true - } - } - return changed ? next : prev - }) - }, [subTables, configuredSimpleColumns, usedData]) - - const handleActionDisabled = useCallback((row, action) => { - const actionRow = attachParentRow(row, parentRow) - if (action?.condition) { - return !action.condition(actionRow) - } - return false - }, [parentRow]) - - const getActionRow = useCallback( - (rowOriginal) => attachParentRow(rowOriginal, parentRow), - [parentRow] - ) - - // Stable callback for sorting changes. - const handleSortingChange = useCallback((newSorting) => { - setSorting(newSorting ?? []) - }, []) - - // Stable callback for muiTablePaperProps. - const muiTablePaperProps = useCallback( - ({ table }) => ({ - sx: { - ...(table.getState().isFullScreen && { - position: 'fixed !important', - top: '64px !important', - bottom: '0 !important', - left: { - xs: '0 !important', - lg: settings?.sidebarCollapse - ? '73px !important' - : '270px !important', - }, - right: '0 !important', - zIndex: '1300 !important', - m: '0 !important', - p: '16px !important', - overflow: 'auto', - bgcolor: 'background.paper', - maxWidth: 'none !important', - width: 'auto !important', - height: 'auto !important', - }), - }, - }), - [settings?.sidebarCollapse] - ) - - // Memoize row click props for offCanvas navigation. - const muiTableBodyRowProps = useMemo(() => { - if (offCanvasOnRowClick && offCanvas) { - return ({ row }) => ({ - onClick: (event) => { - if ( - event.target?.closest?.( - 'button, a, input, textarea, select, [role="button"], [role="menuitem"], [data-no-row-click="true"]' - ) - ) { - return - } - - setOffCanvasData(row.original) - const navigable = table?.getSortedRowModel?.()?.rows - if (navigable) { - const indexInList = navigable.findIndex( - (r) => r.original === row.original - ) - setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0) - } - setOffcanvasVisible(true) - }, - sx: { - cursor: 'pointer', - '&:hover': { - backgroundColor: 'action.hover', - }, - }, - }) - } - return undefined - }, [offCanvasOnRowClick, offCanvas]) - - // Memoize the empty-rows fallback renderer. - const queueMessage = getRequestData.data?.pages?.[0]?.Metadata?.QueueMessage - const renderEmptyRowsFallback = useCallback( - ({ table }) => - queueMessage ? ( - -
    - {queueMessage} -
    -
    - ) : undefined, - [queueMessage] - ) - - // Compute the skeletons state value. - const showSkeletons = getRequestData.isFetchingNextPage - ? false - : getRequestData.isFetching - ? getRequestData.isFetching - : isFetching - - // Memoize state object to avoid creating a new reference every render when values haven't changed. - const tableState = useMemo( - () => ({ - columnVisibility: sanitizedColumnVisibility, - sorting, - columnFilters, - showSkeletons, - }), - [sanitizedColumnVisibility, sorting, columnFilters, showSkeletons] - ) - - // Single row-action dispatch used by BOTH the desktop row menu and the mobile action - // sheet — the two presentations must not drift. - // `table` is referenced via closure: it is declared below but initialized before any - // handler can run (the same pattern the row menu has always relied on). - const dispatchRowAction = useCallback( - (action, rowOriginal, closeMenu = () => {}) => { - const scopeToRowTenant = () => { - const tenant = getRowTenant(getActionRow(rowOriginal), settings.currentTenant) - if (settings.currentTenant === 'AllTenants' && tenant && tenant !== 'AllTenants') { - settings.handleUpdate({ - currentTenant: tenant, - }) - } - } - - if (action.noConfirm && action.customFunction) { - scopeToRowTenant() - action.customFunction(getActionRow(rowOriginal), action, {}) - closeMenu() - return - } - - // Handle custom component differently - if (typeof action.customComponent === 'function') { - scopeToRowTenant() - setCustomComponentData({ data: getActionRow(rowOriginal), action: action }) - setCustomComponentVisible(true) - closeMenu() - return - } - - // Standard dialog flow - setActionData({ - data: getActionRow(rowOriginal), - action: action, - ready: true, - }) - createDialog.handleOpen() - closeMenu() - }, - [settings, createDialog, getActionRow] - ) - - // Open the extended-info offcanvas for a row, recording its position in the row model so - // prev/next navigation works. Shared by the row menu, the mobile action sheet, and card - // taps. The SORTED model is the one on screen — the filtered model is pre-sort, so a - // position taken from it stops matching the list the moment a column is sorted. - const openRowOffCanvas = useCallback((rowOriginal) => { - setOffCanvasData(rowOriginal) - const navigable = table.getSortedRowModel().rows - const indexInList = navigable.findIndex((r) => r.original === rowOriginal) - setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0) - setOffcanvasVisible(true) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // the flipped table shows whatever columns are visible; horizontal scroll covers the width - const cardViewSurfaceRef = useRef(null) - const handleViewToggle = useCallback(() => { - const nextView = effectiveViewMode === 'table' ? 'cards' : 'table' - setViewOverride(nextView) - if (nextView === 'table' && isNarrowViewport) { - scrollNodeToScrollableAncestorTop(cardViewSurfaceRef.current) - } - }, [effectiveViewMode, isNarrowViewport]) - - // Memoize renderRowActionMenuItems to avoid re-creating on each render. - const renderRowActionMenuItems = useMemo(() => { - if (actions) { - return ({ closeMenu, row }) => [ - actions - .filter( - // hideCondition removes an action from this row's menu entirely (vs. - // condition, which renders it disabled). - (action) => - typeof action.hideCondition !== 'function' || - !action.hideCondition(getActionRow(row.original)) - ) - .map((action, index) => ( - dispatchRowAction(action, row.original, closeMenu)} - disabled={handleActionDisabled(row.original, action)} - > - - {action.icon} - - {action.label} - - )), - offCanvas && ( - { - closeMenu() - openRowOffCanvas(row.original) - }} - > - - - - More Info - - ), - ] - } - - if (offCanvas) { - return ({ closeMenu, row }) => ( - { - closeMenu() - openRowOffCanvas(row.original) - }} - > - - - - More Info - - ) - } - - return undefined - }, [ - actions, - offCanvas, - dispatchRowAction, - openRowOffCanvas, - handleActionDisabled, - getActionRow, - ]) - - // Stable renderTopToolbar — memoized so MaterialReactTable doesn't re-create the toolbar - // component on every parent render. - const renderTopToolbar = useCallback( - ({ table }) => { - return ( - <> - {!simple && ( - - )} - - ) - }, - [ - simple, - api, - queryKey, - simpleColumns, - data, - columnVisibility, - getRequestData, - usedColumns, - displayColumns, - memoizedData, - title, - actions, - exportEnabled, - refreshFunction, - filters, - graphFilterData, - isInDialog, - showBulkExportAction, - toggleAllowed, - handleViewToggle, - tableViewActive, - showReturnToCards, - isNarrowViewport, - headerBulkSlot, - dataSourceControls, - activeFilters, - searchValue, - ] - ) - - const table = useMaterialReactTable({ - layoutMode: 'grid-no-grow', - enableRowVirtualization: true, - enableColumnVirtualization: true, - enableColumnResizing: true, - columnResizeMode: 'onChange', - rowVirtualizerOptions: { - overscan: 5, - }, - muiTableBodyCellProps: MUI_TABLE_BODY_CELL_PROPS, - mrtTheme: MRT_THEME, - muiTablePaperProps, - muiTableBodyRowProps, - enableColumnFilterModes: true, - muiTableHeadCellProps: MUI_TABLE_HEAD_CELL_PROPS, - initialState: { - columnFilters: columnFilters, - columnVisibility: sanitizedColumnVisibility, - }, - columns: displayColumns, - data: memoizedData ?? EMPTY_ARRAY, - state: tableState, - onSortingChange: handleSortingChange, - onColumnFiltersChange: setColumnFilters, - renderEmptyRowsFallback, - onColumnVisibilityChange: setColumnVisibility, - ...modeInfo, - // narrow table views size their scroll viewport from measurement (see the effect below), - // the modeInfo calc budget only holds for desktop chrome - ...(isNarrowViewport && - effectiveViewMode === 'table' && { - muiTableContainerProps: { - ...modeInfo.muiTableContainerProps, - sx: { - ...modeInfo.muiTableContainerProps?.sx, - maxHeight: narrowTableMaxHeight ? `${narrowTableMaxHeight}px` : 'none', - }, - }, - }), - renderRowActionMenuItems, - renderTopToolbar, - sortingFns: SORTING_FNS, - filterFns: FILTER_FNS, - globalFilterFn: 'contains', - enableGlobalFilterModes: true, - renderGlobalFilterModeMenuItems: renderGlobalFilterModeMenuItemsFn, - renderColumnFilterModeMenuItems: renderColumnFilterModeMenuItemsFn, - }) - - // deselect all rows when the underlying data set actually changes, guarded so a toolbar - // remount (cards<->table flip) does not wipe an in-progress selection - const prevUsedDataRef = useRef(memoizedData) - useEffect(() => { - if (prevUsedDataRef.current === memoizedData) { - return - } - prevUsedDataRef.current = memoizedData - table.toggleAllRowsSelected(false) - }, [memoizedData]) - - // utilTableMode seeds columnOrder from simpleColumns (e.g. "members"), but cached report - // data shows membersCsv instead — MRT crashes if order references ids that are not in - // displayColumns. - useEffect(() => { - if (!Array.isArray(subTables) || subTables.length === 0) { - return - } - const displayIds = displayColumns.map((col) => col.id).filter(Boolean) - if (displayIds.length === 0) { - return - } - const currentOrder = table.getState().columnOrder ?? [] - if (!columnOrderHasStaleIds(currentOrder, displayIds)) { - return - } - const selectedForOrder = resolveSubTableSimpleColumns( - configuredSimpleColumns, - subTables, - usedData - ).filter((id) => displayIds.includes(id)) - table.setColumnOrder(orderColumnsBySelection(displayIds, selectedForOrder)) - }, [configuredSimpleColumns, displayColumns, subTables, usedData, table]) - - // size the narrow table's scroll viewport from where it actually sits: viewport height - // minus the container's measured top, the real footer height and the chrome below the - // paper. the desktop calc assumes chrome heights that phone layouts do not have. - // deps include getRequestData.isSuccess so a cold load (table not yet mounted when the - // toggle flips tableViewActive true) re-arms the measurement once MRT actually renders - useEffect(() => { - if (!(isNarrowViewport && tableViewActive)) { - setNarrowTableMaxHeight(null) - return undefined - } - const measure = () => { - const container = table.refs.tableContainerRef?.current - if (!container) { - return - } - const footer = table.refs.bottomToolbarRef?.current?.offsetHeight ?? 0 - // chrome between the paper's bottom edge and the page bottom (CardContent padding + page gap) - const BELOW_PAPER_PX = 40 - // viewport-relative, the toggle handler aligns the card surface with the scroll - // viewport top first so this reads a deterministic position - const top = container.getBoundingClientRect().top - let next = Math.max(240, Math.floor(window.innerHeight - top - footer - BELOW_PAPER_PX)) - // 120 = minimal chrome allowance, keeps the table from claiming the full viewport - next = Math.min(next, window.innerHeight - 120) - setNarrowTableMaxHeight((prev) => { - if (prev !== null && Math.abs(prev - next) <= 1) { - return prev - } - return next - }) - } - const raf = requestAnimationFrame(() => requestAnimationFrame(measure)) - window.addEventListener('resize', measure) - let observer - // the paper mounts in the same commit as the container and the footer, so it is a - // reliable observation target even on the pass where the footer ref is still null - const paper = table.refs.tablePaperRef?.current - if (typeof ResizeObserver !== 'undefined' && paper) { - observer = new ResizeObserver(measure) - observer.observe(paper) - } - return () => { - cancelAnimationFrame(raf) - window.removeEventListener('resize', measure) - observer?.disconnect() - } - }, [isNarrowViewport, tableViewActive, table, getRequestData.isSuccess]) - - // A card shows at most a title, subtitle, three chips and three detail rows, so the rest - // of the row has to live in the drawer. On a page with no offCanvas that means every - // column the user has chosen to show; on a page that configured one, its curated fields - // come first and the remaining visible columns are appended rather than dropped — on - // desktop those columns are still on screen in the table, on mobile they are not. - const cardInfoFields = useMemo(() => { - if (!isCardView) return undefined - // A page that renders its own drawer body (offCanvas.children — the test-detail pages) - // is the authority on what the drawer shows; prepending the generic property list on - // top of it repeated Risk/Status above a body that already presents them. - if (offCanvas?.children) return undefined - const visible = table - .getVisibleLeafColumns() - .map((column) => column.id) - .filter((id) => !id.startsWith('mrt-')) - const curated = offCanvas?.extendedInfoFields - if (!curated?.length) return visible - // Curated order wins; dedupe case-insensitively across both lists, and within the - // curated list itself, so a field can't be shown twice. - const seen = new Set() - return [...curated, ...visible].filter((id) => { - if (typeof id !== 'string') return false - const key = id.toLowerCase() - if (seen.has(key)) return false - seen.add(key) - return true - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [offCanvas, isCardView, table, columnVisibility, usedColumns]) - - // Applied after the {...offCanvas} spread below, which carries the page's own - // extendedInfoFields and would otherwise overwrite the merged list. Card view renders - // them the way the table cells do, since the appended entries are columns. - const cardInfoOverride = - isCardView && cardInfoFields?.length - ? { extendedInfoFields: cardInfoFields, richFormatting: true } - : {} - - // The rows the drawer's Prev/Next walks, in the order they are on screen. Read live from - // the table on every render: this used to be mirrored into state by an effect keyed on - // filters and sorting, so the copy was taken once at mount — before the rows had arrived - // — and every table that loads its data asynchronously reported nothing to navigate. - const navigationRows = table.getSortedRowModel().rows - // Prefer the position of the row actually on show, so sorting or filtering while the - // drawer is open carries the counter with it. The stored index covers the case where the - // row has left the list entirely — a background refetch replaces every object, so - // identity alone would strand the position — and is clamped so a list that shrank under - // it can't report "6 of 2". - const derivedRowIndex = offcanvasVisible - ? navigationRows.findIndex((row) => row.original === offCanvasData) - : -1 - const currentRowIndex = - derivedRowIndex >= 0 - ? derivedRowIndex - : Math.min(offCanvasRowIndex, Math.max(navigationRows.length - 1, 0)) - - // Remove the useEffect that was resetting filters on table changes - // The initial filter application is now handled by the columnFilters state - // and the useEffect above that only triggers on actual filter prop changes - - // Exiting mobile select mode clears the selection — "Done" means done. - const handleMobileSelectModeChange = useCallback( - (on) => { - setMobileSelectMode(on) - if (!on) { - table.toggleAllRowsSelected(false) - } - }, - [table] - ) - - // Empty-state "Clear filters" in the card list. The full reset (graph filters, - // persisted slots) lives in the toolbar's filter sheet; this only clears what makes - // the current list empty. - const handleClearAllFilters = useCallback(() => { - table.resetGlobalFilter() - table.resetColumnFilters() - }, [table]) - - useEffect(() => { - if (onChange && table.getSelectedRowModel().rows) { - onChange(table.getSelectedRowModel().rows.map((row) => row.original)) - } - }, [table.getSelectedRowModel().rows]) - - useEffect(() => { - //check if the simplecolumns are an array, - if (Array.isArray(simpleColumns) && simpleColumns.length > 0) { - setConfiguredSimpleColumns(simpleColumns) - } - }, [simpleColumns]) - - const selectModeActive = hasOnChange ? true : mobileSelectMode - - const resolvedCardButton = cardButton ? ( - - ) : undefined - - // below md, table-in-Card branch: the actions FAB carries cardButton - const headerAction = isNarrowViewport && !isInDialog ? undefined : resolvedCardButton - - return ( - <> - {isCardView ? ( - // same paper surface as the table path; overflow visible keeps the controls bar sticky - - {!hideTitle && ( - - - {title} - - {Array.isArray(usedData) && !showSkeletons && ( - - {table.getFilteredRowModel().rows.length} results - - )} - - )} - {!Array.isArray(usedData) && usedData ? ( - - ) : ( - <> - - - - )} - {getRequestData.isError && !getRequestData.isFetchNextPageError && ( - getRequestData.refetch()} - message={`Error Loading data: ${getCippError(getRequestData.error)}`} - /> - )} - - ) : noCard ? ( - - {!Array.isArray(usedData) && usedData ? ( - - ) : ( - <> - {(getRequestData.isSuccess || - getRequestData.data?.pages.length >= 0 || - data) && } - - )} - {getRequestData.isError && !getRequestData.isFetchNextPageError && ( - getRequestData.refetch()} - message={`Error Loading data: ${getCippError(getRequestData.error)}`} - /> - )} - - ) : ( - // Render the table inside a Card - <> - - {cardButton || !hideTitle ? ( - <> - - - {/* narrow viewports carry these in the Table options sheet */} - {dataSourceControls && !isNarrowViewport ? ( - - {dataSourceControls} - {headerAction} - - ) : ( - headerAction - )} - - } - title={hideTitle ? '' : title} - {...props.cardHeaderProps} - /> - - - ) : null} - - - {!Array.isArray(usedData) && usedData ? ( - - ) : ( - <> - {(getRequestData.isSuccess || - getRequestData.data?.pages.length >= 0 || - (data && !getRequestData.isError)) && ( - - )} - - )} - {getRequestData.isError && - !getRequestData.isFetchNextPageError && ( - getRequestData.refetch()} - message={`Error Loading data: ${getCippError(getRequestData.error)}`} - /> - )} - - - - {isNarrowViewport && !isInDialog && resolvedCardButton && ( - {resolvedCardButton} - )} - - )} - setOffcanvasVisible(false)} - extendedData={offCanvasData} - extendedInfoFields={offCanvas?.extendedInfoFields} - title={offCanvasData?.Name || offCanvas?.title || 'Extended Info'} - aboveModal={isInDialog} - children={ - offCanvas?.children - ? (row) => offCanvas.children(row, currentRowIndex) - : undefined - } - customComponent={offCanvas?.customComponent} - onNavigateUp={() => { - const newIndex = currentRowIndex - 1 - if (newIndex >= 0 && navigationRows[newIndex]) { - setOffCanvasRowIndex(newIndex) - setOffCanvasData(navigationRows[newIndex].original) - } - }} - onNavigateDown={() => { - const newIndex = currentRowIndex + 1 - if (navigationRows[newIndex]) { - setOffCanvasRowIndex(newIndex) - setOffCanvasData(navigationRows[newIndex].original) - } - }} - canNavigateUp={currentRowIndex > 0} - canNavigateDown={currentRowIndex < navigationRows.length - 1} - navigationPosition={{ - index: currentRowIndex + 1, - total: navigationRows.length, - }} - {...offCanvas} - {...cardInfoOverride} - // Retired: the drawer's action buttons never dispatched reliably, and the row menu - // is the actions surface. Last so it beats page configs carrying offCanvas.actions. - actions={undefined} - /> - {/* Render custom component */} - {customComponentVisible && - customComponentData?.action && - typeof customComponentData.action.customComponent === 'function' && - customComponentData.action.customComponent(customComponentData.data, { - drawerVisible: customComponentVisible, - setDrawerVisible: setCustomComponentVisible, - fromRowAction: true, - })} - - {/* Render standard dialog */} - {useMemo(() => { - if ( - !actionData.ready || - (actionData.action && - typeof actionData.action.customComponent === 'function') - ) - return null - return ( - - ) - }, [ - actionData.ready, - createDialog, - actionData.action, - actionData.data, - queryKey, - title, - ])} - - ) -} diff --git a/src/components/CippTable/CippDataTable.jsx b/src/components/CippTable/CippDataTable.jsx new file mode 100644 index 000000000000..f70008c6b8ac --- /dev/null +++ b/src/components/CippTable/CippDataTable.jsx @@ -0,0 +1,2344 @@ +import { + MaterialReactTable, + useMaterialReactTable, + getColumnId, + getDefaultColumnFilterFn, +} from 'material-react-table' +import { CippIcons } from '../../utils/icon-registry' +import { + alpha, + Card, + CardContent, + CardHeader, + Chip, + Divider, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + MenuList, + SvgIcon, + Tooltip, + Typography, +} from '@mui/material' +import { ResourceUnavailable } from '../resource-unavailable' +import { ResourceError } from '../resource-error' +import { Scrollbar } from '../scrollbar' +import { useCallback, useEffect, useMemo, useState, useRef } from 'react' +import { useRouter } from 'next/router' +import { ApiGetCallWithPagination } from '../../api/ApiCall' +import { utilTableMode } from './util-tablemode' +import { + utilColumnsFromAPI, + resolveSimpleColumnVariables, +} from './util-columnsFromAPI' +import { CIPPTableToptoolbar } from './CIPPTableToptoolbar' +import { CippOffCanvas } from '../CippComponents/CippOffCanvas' +import { useDialog } from '../../hooks/use-dialog' +import { CippApiDialog } from '../CippComponents/CippApiDialog' +import { getCippError } from '../../utils/get-cipp-error' +import { Box, Stack } from '@mui/system' +import { useSettings } from '../../hooks/use-settings' +import { parseCippDate } from '../../utils/parse-cipp-date' +import { isEqual } from 'lodash' // Import lodash for deep comparison +import { useLicenseBackfill } from '../../hooks/use-license-backfill' +import { licenseIncludesFilterFn } from '../../utils/get-cipp-filter-variant' +import { useTableViewMode, useIsNarrowForTables } from '../../hooks/use-breakpoint' +import { CippMobileCardList } from './CippMobileCardList' +import { CippPageActionsFab } from '../CippComponents/CippPageActionsFab' +import CippDataTableButton from './CippDataTableButton' +import { CippTableCardButton } from './CippTableCardButton' +import { + subTableIsSelected, + subTableShowsCachedColumn, + resolveSubTableSimpleColumns, + getSubTableDisplayColumnIds, + getInactiveSubTableColumnIds, + sanitizeColumnOrder, +} from './util-subTables' +import { attachParentRow, getRowTenant } from '../../utils/resolve-row-templates' +import { + dispatchRowOpen, + partitionRowMenuActions, + filterVisibleRowActions, + rowOpenEnabled, + rowOpenSupportsNewTab, +} from './util-row-open' +import { + hasTextSelection, + isRowTextInteraction, +} from './util-row-text-interaction' + +const ROW_CONTEXT_MENU_MAX_HEIGHT = 360 + +// Resolve dot-delimited property paths against arbitrary data objects. +const getNestedValue = (source, path) => { + if (!source) { + return undefined + } + if (!path) { + return source + } + + return path.split('.').reduce((acc, key) => { + if (acc === undefined || acc === null) { + return undefined + } + if (typeof acc !== 'object') { + return undefined + } + return acc[key] + }, source) +} + +// Resolve dot-delimited column ids against the original row data so nested fields can sort/filter properly. +const getRowValueByColumnId = (row, columnId) => { + if (!row?.original || !columnId) { + return undefined + } + + if (columnId.includes('@odata')) { + return row.original[columnId] + } + + return getNestedValue(row.original, columnId) +} + +const compareNullable = (aVal, bVal) => { + if (aVal === null && bVal === null) { + return 0 + } + if (aVal === null) { + return 1 + } + if (bVal === null) { + return -1 + } + if (aVal === bVal) { + return 0 + } + return aVal > bVal ? 1 : -1 +} + +// walk up from the card surface to the page's scrolling ancestor (LayoutContainer, +// overflowY auto) and align the surface with its top. the table flips in at the same +// page slot, so the height measurement taken right after reads a deterministic top +// and pages with content above the table keep the table in view +const scrollNodeToScrollableAncestorTop = (node) => { + if (!node) { + return + } + let ancestor = node.parentElement + while (ancestor && ancestor !== document.body) { + const overflowY = window.getComputedStyle(ancestor).overflowY + if (overflowY === 'auto' || overflowY === 'scroll') { + ancestor.scrollTop += node.getBoundingClientRect().top - ancestor.getBoundingClientRect().top + return + } + ancestor = ancestor.parentElement + } + window.scrollTo(0, window.scrollY + node.getBoundingClientRect().top) +} + +/** + * Column order for a user-curated selection: the selected ids first, in selection order, + * then everything else in its existing order. Exported for tests. + * + * MRT only reads initialState.columnOrder once, so when the graph filter swaps in a new + * \$select list after mount, the new columns (dot-delimited nested fields included) were + * appended last — and the card view's three detail slots are filled in column order, so a + * field the user explicitly selected was exactly the one that overflowed into "+N more". + */ +export const orderColumnsBySelection = (allIds, selectedIds) => { + const selected = selectedIds.filter((id) => allIds.includes(id)) + const rest = allIds.filter((id) => !selected.includes(id)) + return [...selected, ...rest] +} + +// ── Module-level constants ────────────────────────────────────────────────── +// These never change between renders, so extracting them avoids creating new +// object references on every render cycle. + +// Stable ref so an undefined `data` prop doesn't create a fresh [] each render +// and loop the static-data sync effect. +const EMPTY_ARRAY = [] + +const buildSubTableColumn = (sub) => ({ + id: sub.id, + header: sub.header ?? sub.id, + size: sub.size ?? 120, + minSize: sub.minSize ?? 100, + enableSorting: false, + enableColumnFilter: false, + enableGlobalFilter: false, + accessorFn: (row) => { + if (typeof sub.label === 'function') { + return sub.label(row) + } + return sub.label ?? 'View' + }, + Cell: ({ row }) => ( + + ), +}) + +const SORTING_FNS = { + dateTimeNullsLast: (a, b, id) => { + const aRaw = getRowValueByColumnId(a, id) + const bRaw = getRowValueByColumnId(b, id) + const aDate = aRaw ? parseCippDate(aRaw) : null + const bDate = bRaw ? parseCippDate(bRaw) : null + const aTime = + aDate && !Number.isNaN(aDate.getTime()) ? aDate.getTime() : null + const bTime = + bDate && !Number.isNaN(bDate.getTime()) ? bDate.getTime() : null + + return compareNullable(aTime, bTime) + }, + number: (a, b, id) => { + const aRaw = getRowValueByColumnId(a, id) + const bRaw = getRowValueByColumnId(b, id) + const aNum = typeof aRaw === 'number' ? aRaw : Number(aRaw) + const bNum = typeof bRaw === 'number' ? bRaw : Number(bRaw) + const aVal = Number.isNaN(aNum) ? null : aNum + const bVal = Number.isNaN(bNum) ? null : bNum + + return compareNullable(aVal, bVal) + }, + boolean: (a, b, id) => { + const aRaw = getRowValueByColumnId(a, id) + const bRaw = getRowValueByColumnId(b, id) + const toBool = (value) => { + if (value === null || value === undefined) { + return null + } + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + const lower = value.toLowerCase() + if (lower === 'true' || lower === 'yes') { + return true + } + if (lower === 'false' || lower === 'no') { + return false + } + } + if (typeof value === 'number') { + return value !== 0 + } + return null + } + + const aBool = toBool(aRaw) + const bBool = toBool(bRaw) + const aNumeric = aBool === null ? null : aBool ? 1 : 0 + const bNumeric = bBool === null ? null : bBool ? 1 : 0 + + return compareNullable(aNumeric, bNumeric) + }, +} + +const FILTER_FNS = { + // TanStack has no built-in "includes" filter fn (only "includesString"); columns built by + // get-cipp-filter-variant.js use this name, so it must be registered here to resolve. + includes: (row, columnId, value) => { + const rowValue = row.getValue(columnId) + if (rowValue === null || rowValue === undefined) { + return false + } + return String(rowValue).toLowerCase().includes(String(value).toLowerCase()) + }, + licenseIncludes: licenseIncludesFilterFn, + notContains: (row, columnId, value) => { + const rowValue = row.getValue(columnId) + if (rowValue === null || rowValue === undefined) { + return false + } + + const stringValue = String(rowValue) + if ( + stringValue.includes('[object Object]') || + !stringValue.toLowerCase().includes(value.toLowerCase()) + ) { + return true + } else { + return false + } + }, + regex: (row, columnId, value) => { + try { + const regex = new RegExp(value, 'i') + const rowValue = row.getValue(columnId) + if ( + typeof rowValue === 'string' && + !rowValue.includes('[object Object]') + ) { + return regex.test(rowValue) + } + return false + } catch (error) { + // If regex is invalid, don't filter + return true + } + }, +} + +// Labels for the filterFn names in FILTER_FNS above (and MRT's own custom filter modes) that +// MRT's built-in localization has no entry for — without these the filter helper text reads +// "Filter Mode: undefined". +const MRT_LOCALIZATION_OVERRIDES = { + filterIncludes: 'Contains', + filterNotContains: 'Does Not Contain', + filterRegex: 'Regex', + filterLicenseIncludes: 'Includes', + filterCustom: 'Custom', +} + +// A multi-select column filter (e.g. Assigned Licenses) is a native MUI Select under the +// hood, not an Autocomplete — filterVariant only switches to the Autocomplete component for +// 'autocomplete'. MRT's own multi-select renderValue draws one Chip per pick with no +// wrapping room, so past the first chip the rest are clipped from the header. Override it +// (via the Select's slotProps.select.renderValue MRT forwards from muiFilterTextFieldProps) +// to collapse 2+ picks into a single "N selected" chip whose Tooltip lists every label. +const getFilterSelectOptionLabel = (options, value) => { + const match = options?.find((opt) => + typeof opt === 'string' ? opt === value : opt?.value === value + ) + if (match === undefined) { + return String(value) + } + return typeof match === 'string' ? match : match.label ?? String(value) +} + +const MUI_FILTER_TEXT_FIELD_PROPS = ({ column }) => { + if (column.columnDef.filterVariant !== 'multi-select') { + return {} + } + const options = column.columnDef.filterSelectOptions + const clearSelection = () => column.setFilterValue([]) + const stopMouseDown = (e) => e.stopPropagation() + return { + slotProps: { + select: { + renderValue: (selected) => { + if (!Array.isArray(selected) || selected.length === 0) { + return ( + + {`Filter by ${column.columnDef.header ?? column.id}`} + + ) + } + if (selected.length === 1) { + return ( + + + + ) + } + const labels = selected.map((value) => getFilterSelectOptionLabel(options, value)) + return ( + +
    {label}
    )}> + +
    +
    + ) + }, + }, + }, + } +} + +// Mirrors MRT's own columnFilterFns seeding (useMaterialReactTable's initial useState), +// so a column not yet in state resolves to the same mode name MRT would have picked itself. +const resolveColumnFilterFnName = (col) => { + if (typeof col.filterFn === 'function') { + return col.filterFn.name || 'custom' + } + return col.filterFn ?? getDefaultColumnFilterFn(col) +} + +const MUI_TABLE_HEAD_CELL_PROPS = { + sx: { + '& .MuiTableCell-root': { + padding: '8px 16px', + }, + '& .MuiAutocomplete-root': { + width: '100%', + }, + '& .MuiAutocomplete-root .MuiInputBase-root': { + height: '40px !important', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: 'flex', + flexWrap: 'nowrap', + }, + '& .MuiAutocomplete-root .MuiInputBase-root .MuiInputBase-input': { + height: '24px', + minHeight: '24px', + maxHeight: '24px', + }, + '& .MuiInputBase-root': { + height: '40px !important', + }, + '& .MuiInputBase-input': { + height: '24px', + minHeight: '24px', + maxHeight: '24px', + }, + '& .MuiChip-label.MuiChip-labelMedium': { + maxWidth: '80px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + padding: '0 4px', + }, + '& .MuiChip-root': { + height: '24px', + maxHeight: '24px', + '&::before': { + content: 'attr(data-label)', + display: 'none', + }, + '&:hover::before': { + display: 'block', + position: 'absolute', + top: '-25px', + left: '0', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + color: 'white', + padding: '4px 8px', + borderRadius: '4px', + fontSize: '12px', + whiteSpace: 'nowrap', + zIndex: 9999, + }, + }, + // MRT gives pinned cells `opacity: 0.97` (getCommonMRTCellStyles) so the row scrolling + // underneath a sticky column shows faintly through it; pin it back to opaque here. + '&[data-pinned="true"]': { + opacity: 1, + backgroundColor: 'background.paper', + }, + }, +} + +// Stable keys for row context — reference equality breaks after React Query refetches. +const ROW_CONTEXT_KEY_FIELDS = [ + 'id', + 'RowKey', + 'GUID', + 'userId', + 'appId', + 'azureAdUserId', + 'tenantId', +] + +const getRowContextKey = (row) => { + if (!row || typeof row !== 'object') { + return null + } + for (const field of ROW_CONTEXT_KEY_FIELDS) { + const value = row[field] + if (value != null && value !== '') { + return String(value) + } + } + return null +} + +const rowsMatchContext = (a, b) => { + if (a === b) { + return true + } + const keyA = getRowContextKey(a) + const keyB = getRowContextKey(b) + if (keyA && keyB) { + return keyA === keyB + } + return false +} + +const isRowClickTarget = (event) => + event.target?.closest?.( + 'button, a, input, textarea, select, [role="button"], [role="menuitem"], [data-no-row-click="true"]' + ) + +const MUI_TABLE_BODY_CELL_ON_COPY = (e) => { + const sel = window.getSelection()?.toString() ?? '' + if (sel) { + e.preventDefault() + e.stopPropagation() + e.nativeEvent?.stopImmediatePropagation?.() + e.clipboardData.setData('text/plain', sel) + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(sel).catch(() => {}) + } + } +} + +const MUI_TABLE_BODY_CELL_PROPS = { + onCopy: MUI_TABLE_BODY_CELL_ON_COPY, + sx: { + cursor: 'inherit', + // grid-no-grow: allow plain text to shrink so CSS ellipsis can clip to column width + overflow: 'hidden', + minWidth: 0, + '& .cipp-cell-text': { + cursor: 'text', + userSelect: 'text', + display: 'block', + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + '& .MuiSvgIcon-root': { + cursor: 'inherit', + }, + '& a, & button, & [role="button"], & .MuiChip-root, & .MuiIconButton-root, & .MuiButton-root, & .MuiCheckbox-root': + { + cursor: 'pointer', + }, + // MRT gives pinned cells `opacity: 0.97` (getCommonMRTCellStyles) so the row scrolling + // underneath a sticky column shows faintly through it; pin it back to opaque here. + '&[data-pinned="true"]': { + opacity: 1, + backgroundColor: 'background.paper', + }, + }, +} + +const MRT_THEME = (theme) => ({ + baseBackgroundColor: theme.palette.background.paper, +}) + +// MUI 9 dropped Checkbox's legacy `inputProps`, but MRT_SelectCheckbox still sets it — +// it leaks onto the DOM as `inputprops="[object Object]"` and the real `` loses +// its aria-label. Null it out and restore the label via `slotProps.input` instead. +const MUI_SELECT_CHECKBOX_PROPS = ({ table }) => ({ + inputProps: undefined, + slotProps: { + input: { 'aria-label': table.options.localization.toggleSelectRow }, + }, +}) + +const MUI_SELECT_ALL_CHECKBOX_PROPS = ({ table }) => ({ + inputProps: undefined, + slotProps: { + input: { 'aria-label': table.options.localization.toggleSelectAll }, + }, +}) + +// Compute a lightweight "schema key" from data to decide whether columns need recomputing. +// Only looks at the keys of the first few rows rather than deep-comparing the full dataset. +const computeSchemaKey = (data) => { + if (!Array.isArray(data) || data.length === 0) return '' + const sample = data.slice(0, 3) + const keys = new Set() + for (const row of sample) { + if (row && typeof row === 'object') { + for (const k of Object.keys(row)) keys.add(k) + } + } + return [...keys].sort().join(',') + '|' + data.length +} + +// ── Module-level render helpers for filter mode menus ────────────────────── +function renderGlobalFilterModeMenuItemsFn({ + internalFilterOptions, + onSelectFilterMode, +}) { + const customFilterOptions = [ + { + option: 'regex', + label: 'Regex', + symbol: '(.*)', + }, + ] + + customFilterOptions.forEach((filterOption) => { + if ( + !internalFilterOptions.some( + (option) => option.option === filterOption.option + ) + ) { + internalFilterOptions.push(filterOption) + } + }) + + return internalFilterOptions.map((filterOption) => ( + onSelectFilterMode(filterOption.option)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + }} + > + + {filterOption.symbol} + + {filterOption.label} + + )) +} + +function renderColumnFilterModeMenuItemsFn({ + internalFilterOptions, + onSelectFilterMode, +}) { + const customFilterOptions = [ + { + option: 'notContains', + label: 'Not Contains', + symbol: '!*', + }, + { + option: 'regex', + label: 'Regex', + symbol: '(.*)', + }, + ] + + const combinedFilterOptions = [ + ...internalFilterOptions, + ...customFilterOptions, + ] + + return combinedFilterOptions.map((filterOption) => ( + onSelectFilterMode(filterOption.option)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + }} + > + + {filterOption.symbol} + + {filterOption.label} + + )) +} + +export const CippDataTable = (props) => { + const { + queryKey, + data = EMPTY_ARRAY, + columns = [], + api = {}, + isFetching = false, + columnVisibility: initialColumnVisibility = { + id: false, + RowKey: false, + ETag: false, + PartitionKey: false, + Timestamp: false, + TableTimestamp: false, + }, + exportEnabled = true, + simpleColumns = [], + dataFilter, + dataMap, + actions, + title = 'Report', + simple = false, + cardButton, + offCanvas = false, + offCanvasOnRowClick = false, + rowOpen, + noCard = false, + hideTitle = false, + refreshFunction, + incorrectDataMessage = 'Data not in correct format', + onChange, + filters, + maxHeightOffset = '380px', + defaultSorting = [], + isInDialog = false, + showBulkExportAction = true, + viewMode: viewModeProp, + mobileCard, + dataSourceControls, + subTables = EMPTY_ARRAY, + persistenceKey, + parentRow, + } = props + + // Create a map of column IDs to their filterType for quick lookup. + // Built via Map.set + Object.fromEntries rather than `acc[v.id] = ...`: the column id is + // a computed key from preset data, and a bracket assignment would route a value such as + // '__proto__' through Object.prototype's setter. Map keys never touch the prototype chain. + const filterTypeMap = useMemo(() => { + if (!filters || !Array.isArray(filters)) return {} + return Object.fromEntries( + filters.reduce((acc, filter) => { + if (filter.value && Array.isArray(filter.value)) { + filter.value.forEach((v) => { + if (v.id && filter.filterType) { + acc.set(v.id, filter.filterType) + } + }) + } + return acc + }, new Map()) + ) + }, [filters]) + + // Track if initial filters have been applied + const filtersInitializedRef = useRef(false) + const previousFiltersRef = useRef(null) + + const [columnVisibility, setColumnVisibility] = useState( + initialColumnVisibility + ) + const [configuredSimpleColumns, setConfiguredSimpleColumns] = + useState(simpleColumns) + const [usedData, setUsedData] = useState(data) + const [usedColumns, setUsedColumns] = useState([]) + // Controlled so we can drop stale ids (membersCsv ↔ members) in the same render + // as displayColumns changes — a post-render setColumnOrder is too late; MRT throws first. + const [columnOrder, setColumnOrder] = useState(() => [...simpleColumns]) + const lastOrderedSelectionRef = useRef(null) + const [offcanvasVisible, setOffcanvasVisible] = useState(false) + const [offCanvasData, setOffCanvasData] = useState({}) + const [offCanvasRowIndex, setOffCanvasRowIndex] = useState(0) + const [contextRow, setContextRow] = useState(null) + const [rowContextMenu, setRowContextMenu] = useState(null) + const rowClickStartRef = useRef(null) + const [customComponentData, setCustomComponentData] = useState({}) + const [customComponentVisible, setCustomComponentVisible] = useState(false) + const [actionData, setActionData] = useState({ + data: {}, + action: {}, + ready: false, + }) + const [graphFilterData, setGraphFilterData] = useState({}) + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) + // MRT seeds its own columnFilterFns state once, from whatever `columns` it was given on + // the very first render — here that's always [] (columns arrive async from the API), so + // it's permanently stuck at {} and every filter's helper text reads "Filter Mode: + // undefined". Track and control it ourselves so it re-derives as real columns arrive. + const [columnFilterFns, setColumnFilterFns] = useState({}) + const waitingBool = api?.url ? true : false + + // The cards branch and the renderTopToolbar branch are two alternating CIPPTableToptoolbar + // instances (only one is ever mounted), so state that must survive the cards<->table flip + // lives here and is passed down as props to both. + const [activeFilters, setActiveFilters] = useState({ graph: null, table: null }) + const [searchValue, setSearchValue] = useState('') + const restoredFiltersRef = useRef(new Set()) + + const settings = useSettings() + const router = useRouter() + const routerPageName = router.pathname.split('/').slice(1).join('/') + const pageName = persistenceKey ?? (isInDialog ? '' : routerPageName) + + // 'cards' below the md breakpoint (or when forced via settings/prop), 'table' otherwise. + // simple tables always resolve to 'table'. + const resolvedViewMode = useTableViewMode({ viewMode: viewModeProp, simple }) + // same pivot as the cards/table auto mode, so the FAB and the card list agree on width + const isNarrowViewport = useIsNarrowForTables() + // viewMode prop or simple is a hard force, the toggle never overrides it + const toggleAllowed = !viewModeProp && !simple + // Mobile select mode: checkboxes on cards + the bottom bulk bar. Lives here so the + // toolbar (which renders the Select toggle) and the card list stay in sync. Picker + // tables (onChange) force it on — selection is their entire purpose. + const [mobileSelectMode, setMobileSelectMode] = useState(false) + // portal target for the header's bulk-actions slot, set by the CardHeader's ref callback + const [headerBulkSlot, setHeaderBulkSlot] = useState(null) + + // transient cards<->table override, session-only, never persisted + const [viewOverride, setViewOverride] = useState(null) + const effectiveViewMode = toggleAllowed ? (viewOverride ?? resolvedViewMode) : resolvedViewMode + const isCardView = effectiveViewMode === 'cards' + const tableViewActive = effectiveViewMode === 'table' + const CardViewSurface = noCard ? Box : Card + const [narrowTableMaxHeight, setNarrowTableMaxHeight] = useState(null) + // way back button: table is only up because of the override, phone default is still cards + const showReturnToCards = toggleAllowed && tableViewActive && resolvedViewMode === 'cards' + + // Hook to trigger re-render when license backfill completes + const { updateTrigger } = useLicenseBackfill() + + // Ref to track previous schema key so we only recompute columns when the data shape changes. + const prevSchemaKeyRef = useRef('') + // Ref to track previous data reference for the static-data sync effect. + const prevDataRef = useRef(data) + + const getRequestData = ApiGetCallWithPagination({ + url: api.url, + data: { ...api.data }, + queryKey: queryKey ? queryKey : title, + waiting: waitingBool, + ...graphFilterData, + }) + + useEffect(() => { + // Only set initial filters if they haven't been set yet OR if the filters prop has actually changed + const filtersChanged = !isEqual(filters, previousFiltersRef.current) + + if ( + filters && + Array.isArray(filters) && + filters.length > 0 && + (!filtersInitializedRef.current || filtersChanged) + ) { + // Only auto-apply filters that are in MRT column-filter format ({ id, value }). + // Preset objects ({ filterName, value, type }) are toolbar buttons only — applying them + // directly sets id=undefined which causes MRT to throw. + const columnFormatFilters = filters.filter((f) => f.id !== undefined) + + if (columnFormatFilters.length > 0) { + const processedFilters = columnFormatFilters.map((filter) => { + if (filter.filterType === 'equal') { + return { + ...filter, + value: Array.isArray(filter.value) + ? filter.value + : [filter.value], + } + } + return filter + }) + setColumnFilters(processedFilters) + } + + filtersInitializedRef.current = true + previousFiltersRef.current = filters + } + }, [filters]) + + // Sync static data prop into usedData (only when not using API). + // We avoid including usedData in the dependency array to prevent the expensive + // isEqual check from running on every API-driven data change. + useEffect(() => { + if (Array.isArray(data) && !api?.url) { + // Only update if the data prop reference actually changed. + if (data !== prevDataRef.current) { + prevDataRef.current = data + setUsedData(data) + } + } + }, [data, api?.url]) + + useEffect(() => { + if (getRequestData.isSuccess && !getRequestData.isFetching) { + const lastPage = + getRequestData.data?.pages[getRequestData.data.pages.length - 1] + const nextLinkExists = lastPage?.Metadata?.nextLink + if (nextLinkExists) { + getRequestData.fetchNextPage() + } + } + }, [getRequestData.data?.pages?.length, getRequestData.isFetching, queryKey]) + + useEffect(() => { + if (getRequestData.isSuccess) { + const allPages = getRequestData.data.pages + + const combinedResults = allPages.flatMap((page) => { + const nestedData = getNestedValue(page, api.dataKey) + if (nestedData !== undefined) { + return nestedData + } + // dataKey miss on a bare-array page: the endpoint served the legacy shape. + return Array.isArray(page) ? page : [] + }) + const filtered = dataFilter + ? combinedResults.filter(dataFilter) + : combinedResults + setUsedData( + typeof dataMap === 'function' + ? filtered.map((row) => dataMap(row, { parentRow })) + : filtered + ) + } + }, [ + getRequestData.isSuccess, + getRequestData.data, + api.dataKey, + getRequestData.isFetching, + queryKey, + dataFilter, + dataMap, + parentRow, + ]) + + // Derive columns from data — only when the data schema actually changes. + useEffect(() => { + if ( + !Array.isArray(usedData) || + usedData.length === 0 || + typeof usedData[0] !== 'object' || + usedData === null || + usedData === undefined + ) { + return + } + + const schemaKey = computeSchemaKey(usedData) + // Skip expensive column generation if the schema hasn't changed. + if (schemaKey === prevSchemaKeyRef.current && usedColumns.length > 0) { + return + } + prevSchemaKeyRef.current = schemaKey + + const apiColumns = utilColumnsFromAPI(usedData) + + // Apply custom filterFn to columns that have filterType === 'equal' + const enhancedApiColumns = apiColumns.map((col) => { + if (filterTypeMap[col.id] === 'equal') { + return { + ...col, + filterFn: 'equals', + } + } + return col + }) + + let finalColumns = [] + let newVisibility = { ...columnVisibility } + + // Check if we're in AllTenants mode and data has Tenant property + const isAllTenants = settings?.currentTenant === 'AllTenants' + const hasTenantProperty = usedData.some( + (row) => row && typeof row === 'object' && 'Tenant' in row + ) + const shouldShowTenant = isAllTenants && hasTenantProperty + + if (columns.length === 0 && configuredSimpleColumns.length === 0) { + finalColumns = enhancedApiColumns + enhancedApiColumns.forEach((col) => { + newVisibility[col.id] = true + }) + } else if (configuredSimpleColumns.length > 0) { + // Resolve any variables in the simple columns before checking visibility + const resolvedSimpleColumns = resolveSubTableSimpleColumns( + resolveSimpleColumnVariables( + configuredSimpleColumns, + usedData + ), + subTables, + usedData + ) + + // Add Tenant to resolved columns if in AllTenants mode and not already included + let finalResolvedColumns = [...resolvedSimpleColumns] + if (shouldShowTenant && !resolvedSimpleColumns.includes('Tenant')) { + finalResolvedColumns = [...resolvedSimpleColumns, 'Tenant'] + } + + finalColumns = enhancedApiColumns + finalColumns.forEach((col) => { + if (col.id !== undefined) { + newVisibility[col.id] = finalResolvedColumns.includes(col.id) + } + }) + // Selection order wins over data-key order — but only when the resolved selection + // changed (including subTable cachedColumn swaps), so a data refetch doesn't + // stomp a manual column reorder. + const resolvedOrderKey = finalResolvedColumns.join('|') + if (lastOrderedSelectionRef.current !== resolvedOrderKey) { + lastOrderedSelectionRef.current = resolvedOrderKey + const subTableIds = getSubTableDisplayColumnIds( + subTables, + configuredSimpleColumns, + usedData + ) + const allIds = [ + ...new Set([ + ...finalColumns.map((col) => col.id).filter(Boolean), + ...subTableIds, + ]), + ] + setColumnOrder(orderColumnsBySelection(allIds, finalResolvedColumns)) + } + } else { + const providedColumnKeys = new Set( + columns.map((col) => col.id || col.header) + ) + finalColumns = [ + ...columns, + ...enhancedApiColumns.filter((col) => !providedColumnKeys.has(col.id)), + ] + finalColumns.forEach((col) => { + const key = col.id ?? col.accessorKey + if (key !== undefined) { + newVisibility[key] = providedColumnKeys.has(col.id) + } + }) + + // Handle Tenant column for custom columns case + if (shouldShowTenant) { + const tenantColumn = finalColumns.find((col) => col.id === 'Tenant') + if (tenantColumn) { + // Make tenant visible + newVisibility['Tenant'] = true + } + } + } + if (defaultSorting?.length > 0) { + setSorting(defaultSorting) + } + setUsedColumns(finalColumns) + setColumnVisibility(newVisibility) + }, [ + columns.length, + usedData, + queryKey, + settings?.currentTenant, + filterTypeMap, + subTables, + ]) + + // Previous-value refs for the guards below: CippDataTable is the single owner of this + // state across both toolbar instances, so an effect can compare against the last value + // it actually saw rather than firing unconditionally on every render. + const prevTenantRef = useRef(settings?.currentTenant) + const prevQueryKeyRef = useRef(queryKey || title) + const prevPageNameRef = useRef(pageName) + const appliedColumnDefaultsRef = useRef({}) + + // if the currentTenant switches, remove graph filters and the active-filter highlight + useEffect(() => { + const currentTenant = settings?.currentTenant + if (prevTenantRef.current === currentTenant) { + return + } + prevTenantRef.current = currentTenant + if (currentTenant) { + setGraphFilterData({}) + setActiveFilters({ graph: null, table: null }) + restoredFiltersRef.current.delete(`${pageName}-graph`) + } + }, [settings?.currentTenant, pageName]) + + // clear the active-filter highlight when the effective query key changes (tenant swap, + // different queryKey/title) + useEffect(() => { + const effectiveKey = queryKey || title + if (prevQueryKeyRef.current === effectiveKey) { + return + } + prevQueryKeyRef.current = effectiveKey + setActiveFilters({ graph: null, table: null }) + }, [queryKey, title]) + + // clear persisted-filter restoration tracking only when the page actually changes + useEffect(() => { + if (prevPageNameRef.current === pageName) { + return + } + prevPageNameRef.current = pageName + restoredFiltersRef.current.clear() + }, [pageName]) + + // apply preferred columns once per page, and again whenever the saved preference's + // identity changes. Nested dialog tables must not read or write the parent page key. + useEffect(() => { + if (!pageName) { + return + } + const preferred = settings?.columnDefaults?.[pageName] + if ( + preferred && + Object.keys(preferred).length > 0 && + appliedColumnDefaultsRef.current[pageName] !== preferred + ) { + appliedColumnDefaultsRef.current[pageName] = preferred + setColumnVisibility(preferred) + } + }, [settings?.columnDefaults?.[pageName], pageName]) + + const createDialog = useDialog() + const hasActions = !!actions + const hasOffCanvas = !!offCanvas + const hasOnChange = !!onChange + + // Compute modeInfo via useMemo so it stays stable but updates when relevant inputs change. + const modeInfo = useMemo( + () => + utilTableMode( + columnVisibility, + simple, + actions, + configuredSimpleColumns, + offCanvas, + onChange, + maxHeightOffset, + settings, + effectiveViewMode, + isNarrowViewport, + exportEnabled + ), + [ + simple, + hasActions, + hasOffCanvas, + hasOnChange, + maxHeightOffset, + settings?.tablePageSize?.value, + effectiveViewMode, + isNarrowViewport, + exportEnabled, + ] + ) + + // Include updateTrigger in data memo to force re-render when license backfill completes. + // Also refresh data identity when derived columns change so TanStack re-runs filtering + // for searches entered before columns are available. + const memoizedData = useMemo( + () => (Array.isArray(usedData) ? usedData.slice() : usedData), + [usedData, updateTrigger, usedColumns] + ) + + // Sanitize columnVisibility to remove any undefined/invalid keys before passing to MRT + const sanitizedColumnVisibility = useMemo(() => { + const result = {} + for (const key of Object.keys(columnVisibility)) { + if (key !== 'undefined' && key !== undefined) + result[key] = columnVisibility[key] + } + return result + }, [columnVisibility]) + + const displayColumns = useMemo(() => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return usedColumns + } + const cachedHeaders = new Map() + const injected = [] + const excludedIds = new Set( + getInactiveSubTableColumnIds(subTables, configuredSimpleColumns, usedData) + ) + for (const sub of subTables) { + if (!subTableIsSelected(sub, configuredSimpleColumns)) { + continue + } + if (subTableShowsCachedColumn(sub, usedData)) { + cachedHeaders.set(sub.cachedColumn, sub.header ?? sub.id) + continue + } + injected.push(buildSubTableColumn(sub)) + } + const columns = cachedHeaders.size + ? usedColumns.map((col) => + cachedHeaders.has(col.id) + ? { ...col, header: cachedHeaders.get(col.id) } + : col + ) + : usedColumns + const injectedById = new Map(injected.map((col) => [col.id, col])) + const replaced = columns.map((col) => injectedById.get(col.id) ?? col) + const existing = new Set(columns.map((col) => col.id)) + return [...replaced, ...injected.filter((col) => !existing.has(col.id))].filter( + (col) => !excludedIds.has(col.id) + ) + }, [usedColumns, usedData, subTables, configuredSimpleColumns]) + + const displayColumnIds = useMemo( + () => displayColumns.map((col) => col.id).filter(Boolean), + [displayColumns] + ) + + // The default filter-mode name for every currently-displayed column (see + // resolveColumnFilterFnName above). Recomputed whenever the column set changes so newly + // arrived columns get a mode name without waiting on a user click. + const derivedColumnFilterFns = useMemo(() => { + const map = {} + for (const col of displayColumns) { + const id = col.id ?? getColumnId(col) + if (id) { + map[id] = resolveColumnFilterFnName(col) + } + } + return map + }, [displayColumns]) + + // Seed columnFilterFns for ids that don't have one yet — a user-picked mode (from the + // filter-mode menu, via onColumnFilterFnsChange below) is never overwritten here. + useEffect(() => { + setColumnFilterFns((prev) => { + let changed = false + const next = { ...prev } + for (const [id, fn] of Object.entries(derivedColumnFilterFns)) { + if (next[id] === undefined) { + next[id] = fn + changed = true + } + } + return changed ? next : prev + }) + }, [derivedColumnFilterFns]) + + // Remount MRT when the visible column identity set changes (cache↔live). MRT's + // column virtualizer memoizes pinned indexes without depending on column count; + // without a remount, TableHeadRow maps a sparse virtual item and reads `.index` + // on undefined. + const mrtColumnSetKey = useMemo(() => displayColumnIds.join('|'), [displayColumnIds]) + + const preferredColumnOrderIds = useMemo( + () => + resolveSubTableSimpleColumns( + configuredSimpleColumns, + subTables, + usedData + ).filter((id) => displayColumnIds.includes(id)), + [configuredSimpleColumns, subTables, usedData, displayColumnIds] + ) + + // Same-render sanitize: toggling ReportDB cache↔live swaps membersCsv↔members (and + // drops CacheTimestamp). MRT column virtualization reads virtualItem.index during + // render and throws if columnOrder still names the old ids ("can't access property + // index, e is undefined" in Firefox). A useEffect setColumnOrder runs too late. + const safeColumnOrder = useMemo( + () => + sanitizeColumnOrder(columnOrder, displayColumnIds, preferredColumnOrderIds), + [columnOrder, displayColumnIds, preferredColumnOrderIds] + ) + + useEffect(() => { + if (safeColumnOrder === columnOrder) { + return + } + setColumnOrder(safeColumnOrder) + }, [safeColumnOrder, columnOrder]) + + useEffect(() => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return + } + setColumnVisibility((prev) => { + const next = { ...prev } + let changed = false + for (const sub of subTables) { + if (!subTableIsSelected(sub, configuredSimpleColumns)) { + continue + } + const columnId = subTableShowsCachedColumn(sub, usedData) + ? sub.cachedColumn + : sub.id + if (next[columnId] === undefined) { + next[columnId] = true + changed = true + } + } + return changed ? next : prev + }) + }, [subTables, configuredSimpleColumns, usedData]) + + const handleActionDisabled = useCallback((row, action) => { + const actionRow = attachParentRow(row, parentRow) + if (action?.condition) { + return !action.condition(actionRow) + } + return false + }, [parentRow]) + + const getActionRow = useCallback( + (rowOriginal) => attachParentRow(rowOriginal, parentRow), + [parentRow] + ) + + // Stable callback for sorting changes. + const handleSortingChange = useCallback((newSorting) => { + setSorting(newSorting ?? []) + }, []) + + // Stable callback for muiTablePaperProps. + const muiTablePaperProps = useCallback( + ({ table }) => ({ + sx: { + ...(table.getState().isFullScreen && { + position: 'fixed !important', + top: '64px !important', + bottom: '0 !important', + left: { + xs: '0 !important', + lg: settings?.sidebarCollapse + ? '73px !important' + : '270px !important', + }, + right: '0 !important', + zIndex: '1300 !important', + m: '0 !important', + p: '16px !important', + overflow: 'auto', + bgcolor: 'background.paper', + maxWidth: 'none !important', + width: 'auto !important', + height: 'auto !important', + }), + }, + }), + [settings?.sidebarCollapse] + ) + + // Memoize the empty-rows fallback renderer. + const queueMessage = getRequestData.data?.pages?.[0]?.Metadata?.QueueMessage + const renderEmptyRowsFallback = useCallback( + ({ table }) => + queueMessage ? ( + +
    + {queueMessage} +
    +
    + ) : undefined, + [queueMessage] + ) + + // Compute the skeletons state value. + const showSkeletons = getRequestData.isFetchingNextPage + ? false + : getRequestData.isFetching + ? getRequestData.isFetching + : isFetching + + // Memoize state object to avoid creating a new reference every render when values haven't changed. + const tableState = useMemo( + () => ({ + columnVisibility: sanitizedColumnVisibility, + columnOrder: safeColumnOrder, + sorting, + columnFilters, + columnFilterFns, + showSkeletons, + }), + [ + sanitizedColumnVisibility, + safeColumnOrder, + sorting, + columnFilters, + columnFilterFns, + showSkeletons, + ] + ) + + // Single row-action dispatch used by BOTH the desktop row menu and the mobile action + // sheet — the two presentations must not drift. + // `table` is referenced via closure: it is declared below but initialized before any + // handler can run (the same pattern the row menu has always relied on). + const dispatchRowAction = useCallback( + (action, rowOriginal, closeMenu = () => {}) => { + const scopeToRowTenant = () => { + const tenant = getRowTenant(getActionRow(rowOriginal), settings.currentTenant) + if (settings.currentTenant === 'AllTenants' && tenant && tenant !== 'AllTenants') { + settings.handleUpdate({ + currentTenant: tenant, + }) + } + } + + if (action.noConfirm && action.customFunction) { + scopeToRowTenant() + action.customFunction(getActionRow(rowOriginal), action, {}) + closeMenu() + return + } + + // Handle custom component differently + if (typeof action.customComponent === 'function') { + scopeToRowTenant() + setCustomComponentData({ data: getActionRow(rowOriginal), action: action }) + setCustomComponentVisible(true) + closeMenu() + return + } + + // Standard dialog flow + setActionData({ + data: getActionRow(rowOriginal), + action: action, + ready: true, + }) + createDialog.handleOpen() + closeMenu() + }, + [settings, createDialog, getActionRow] + ) + + // Open the extended-info offcanvas for a row, recording its position in the row model so + // prev/next navigation works. Shared by the row menu, the mobile action sheet, and card + // taps. The SORTED model is the one on screen — the filtered model is pre-sort, so a + // position taken from it stops matching the list the moment a column is sorted. + const openRowOffCanvas = useCallback((rowOriginal) => { + setOffCanvasData(rowOriginal) + setContextRow(rowOriginal) + const navigable = table.getSortedRowModel().rows + const indexInList = navigable.findIndex((r) => + rowsMatchContext(r.original, rowOriginal) + ) + setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0) + setOffcanvasVisible(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const closeRowOffCanvas = useCallback(() => { + setOffcanvasVisible(false) + setContextRow(null) + }, []) + + const handleRowOpenNewTab = useCallback( + (rowOriginal) => { + const actionRow = getActionRow(rowOriginal) + const tenant = getRowTenant(actionRow, settings.currentTenant) + const rowTenant = + tenant && tenant !== 'AllTenants' ? tenant : undefined + const dispatchOptions = { + fallbackTenant: rowTenant, + currentTenant: settings.currentTenant, + newTab: true, + } + if (!rowOpenSupportsNewTab(rowOpen, actionRow, dispatchOptions)) { + return + } + dispatchRowOpen(rowOpen, actionRow, router, dispatchOptions) + }, + [rowOpen, getActionRow, settings, router] + ) + + const closeRowContextMenu = useCallback(() => { + setRowContextMenu(null) + }, []) + + const getRowOpenDispatchOptions = useCallback( + (rowOriginal) => { + const actionRow = getActionRow(rowOriginal) + const tenant = getRowTenant(actionRow, settings.currentTenant) + const rowTenant = + tenant && tenant !== 'AllTenants' ? tenant : undefined + return { + actionRow, + rowTenant, + dispatchOptions: { + fallbackTenant: rowTenant, + currentTenant: settings.currentTenant, + }, + } + }, + [getActionRow, settings] + ) + + const getRowMenuActions = useCallback( + (rowOriginal) => { + const { actionRow, dispatchOptions } = + getRowOpenDispatchOptions(rowOriginal) + const visible = filterVisibleRowActions(actions, actionRow) + const { pinnedActions, menuActions } = partitionRowMenuActions(visible) + return { pinnedActions, menuActions, actionRow, dispatchOptions } + }, + [actions, rowOpen, getRowOpenDispatchOptions] + ) + + // Desktop: middle-click or Ctrl/Cmd+click opens the resolved rowOpen href in a new + // tab; right-click opens the row context menu. Plain clicks only act on pages that + // opt in with offCanvasOnRowClick, where a single click opens the extended-info + // offcanvas. That preview is also in the context menu, the row ⋮ menu, and mobile tap. + const muiTableBodyRowProps = useMemo(() => { + const newTabOpen = Boolean(rowOpen?.link || rowOpen?.onOpen) + const clickOpensOffCanvas = Boolean(offCanvasOnRowClick && offCanvas) + const hasContextMenu = Boolean( + (actions && actions.length > 0) || offCanvas || newTabOpen + ) + + if (!hasContextMenu && !newTabOpen && !clickOpensOffCanvas) { + return undefined + } + + return ({ row }) => { + const actionRow = getActionRow(row.original) + const canOpen = newTabOpen && rowOpenEnabled(rowOpen, actionRow) + const isContextRow = + Boolean(offCanvas) && rowsMatchContext(contextRow, row.original) + + const rowProps = { + onContextMenu: (event) => { + if (!hasContextMenu) { + return + } + if (isRowClickTarget(event) || hasTextSelection()) { + return + } + event.preventDefault() + setRowContextMenu({ + mouseX: event.clientX + 2, + mouseY: event.clientY - 6, + rowOriginal: row.original, + }) + }, + sx: { + cursor: clickOpensOffCanvas ? 'pointer' : undefined, + borderLeft: (theme) => + isContextRow + ? `3px solid ${theme.palette.primary.main}` + : '3px solid transparent', + ...(isContextRow && { + bgcolor: (theme) => + alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.14 : 0.08 + ), + '&:hover': { + bgcolor: (theme) => + alpha( + theme.palette.primary.main, + theme.palette.mode === 'dark' ? 0.22 : 0.12 + ), + }, + }), + ...(!isContextRow && { + '&:hover': { + backgroundColor: 'action.hover', + }, + }), + }, + } + + if (newTabOpen || clickOpensOffCanvas) { + Object.assign(rowProps, { + onMouseDown: (event) => { + if (isRowClickTarget(event)) { + return + } + if (event.button === 1) { + event.preventDefault() + } + rowClickStartRef.current = { x: event.clientX, y: event.clientY } + }, + onClick: (event) => { + if (isRowClickTarget(event)) { + return + } + if (event.ctrlKey || event.metaKey) { + if (canOpen) { + event.preventDefault() + handleRowOpenNewTab(row.original) + } + return + } + if (!clickOpensOffCanvas) { + return + } + if ( + isRowTextInteraction(rowClickStartRef.current, event) || + hasTextSelection() + ) { + return + } + openRowOffCanvas(row.original) + }, + onAuxClick: (event) => { + if (event.button !== 1 || isRowClickTarget(event) || !canOpen) { + return + } + event.preventDefault() + handleRowOpenNewTab(row.original) + }, + }) + } + + return rowProps + } + }, [ + offCanvas, + offCanvasOnRowClick, + rowOpen, + actions, + contextRow, + getActionRow, + handleRowOpenNewTab, + openRowOffCanvas, + ]) + + // the flipped table shows whatever columns are visible; horizontal scroll covers the width + const cardViewSurfaceRef = useRef(null) + const handleViewToggle = useCallback(() => { + const nextView = effectiveViewMode === 'table' ? 'cards' : 'table' + setViewOverride(nextView) + if (nextView === 'table' && isNarrowViewport) { + scrollNodeToScrollableAncestorTop(cardViewSurfaceRef.current) + } + }, [effectiveViewMode, isNarrowViewport]) + + // Memoize renderRowActionMenuItems to avoid re-creating on each render. + const renderRowActionMenuItems = useMemo(() => { + if (actions) { + return ({ closeMenu, row }) => { + const { actionRow } = getRowOpenDispatchOptions(row.original) + const visible = filterVisibleRowActions(actions, actionRow) + // Pinned actions stay in the order they are declared in the actions array. + const { pinnedActions, menuActions } = partitionRowMenuActions(visible) + return [ + pinnedActions.map((action, index) => ( + dispatchRowAction(action, row.original, closeMenu)} + disabled={handleActionDisabled(row.original, action)} + > + + {action.icon} + + {action.label} + + )), + menuActions.map((action, index) => ( + dispatchRowAction(action, row.original, closeMenu)} + disabled={handleActionDisabled(row.original, action)} + > + + {action.icon} + + {action.label} + + )), + offCanvas && ( + { + closeMenu() + openRowOffCanvas(row.original) + }} + > + + + + More Info + + ), + ] + } + } + + if (offCanvas) { + // Must return an array: MRT only renders the row-actions toggle when the + // returned value has a length. + return ({ closeMenu, row }) => [ + { + closeMenu() + openRowOffCanvas(row.original) + }} + > + + + + More Info + , + ] + } + + return undefined + }, [ + actions, + offCanvas, + dispatchRowAction, + openRowOffCanvas, + handleActionDisabled, + getRowOpenDispatchOptions, + ]) + + const rowContextMenuContent = useMemo(() => { + if (!rowContextMenu) { + return null + } + const rowOriginal = rowContextMenu.rowOriginal + const { pinnedActions, menuActions } = getRowMenuActions(rowOriginal) + const hasPinned = Boolean(offCanvas) || pinnedActions.length > 0 + const items = [] + + pinnedActions.forEach((action, index) => { + items.push( + + dispatchRowAction(action, rowOriginal, closeRowContextMenu) + } + disabled={handleActionDisabled(rowOriginal, action)} + > + + {action.icon} + + {action.label} + + ) + }) + if (offCanvas) { + items.push( + { + closeRowContextMenu() + openRowOffCanvas(rowOriginal) + }} + > + + + + More Info + + ) + } + if (hasPinned && menuActions.length > 0) { + items.push() + } + if (menuActions.length > 0) { + items.push( + + {menuActions.map((action, index) => ( + + dispatchRowAction(action, rowOriginal, closeRowContextMenu) + } + disabled={handleActionDisabled(rowOriginal, action)} + > + + {action.icon} + + {action.label} + + ))} + + ) + } + + return items + }, [ + rowContextMenu, + offCanvas, + getRowMenuActions, + closeRowContextMenu, + openRowOffCanvas, + dispatchRowAction, + handleActionDisabled, + ]) + + // Stable renderTopToolbar — memoized so MaterialReactTable doesn't re-create the toolbar + // component on every parent render. + const renderTopToolbar = useCallback( + ({ table }) => { + return ( + <> + {!simple && ( + + )} + + ) + }, + [ + simple, + api, + queryKey, + simpleColumns, + data, + columnVisibility, + getRequestData, + usedColumns, + displayColumns, + memoizedData, + title, + actions, + exportEnabled, + refreshFunction, + filters, + graphFilterData, + isInDialog, + showBulkExportAction, + toggleAllowed, + handleViewToggle, + tableViewActive, + showReturnToCards, + isNarrowViewport, + headerBulkSlot, + dataSourceControls, + activeFilters, + searchValue, + ] + ) + + const table = useMaterialReactTable({ + layoutMode: 'grid-no-grow', + enableRowVirtualization: true, + enableColumnVirtualization: true, + enableColumnResizing: true, + columnResizeMode: 'onChange', + rowVirtualizerOptions: { + overscan: 5, + }, + muiTableBodyCellProps: MUI_TABLE_BODY_CELL_PROPS, + mrtTheme: MRT_THEME, + muiTablePaperProps, + muiTableBodyRowProps, + enableColumnFilterModes: true, + muiTableHeadCellProps: MUI_TABLE_HEAD_CELL_PROPS, + initialState: { + columnFilters: columnFilters, + columnVisibility: sanitizedColumnVisibility, + }, + columns: displayColumns, + data: memoizedData ?? EMPTY_ARRAY, + state: tableState, + onSortingChange: handleSortingChange, + onColumnFiltersChange: setColumnFilters, + onColumnFilterFnsChange: setColumnFilterFns, + renderEmptyRowsFallback, + onColumnVisibilityChange: setColumnVisibility, + onColumnOrderChange: setColumnOrder, + ...modeInfo, + // narrow table views size their scroll viewport from measurement (see the effect below), + // the modeInfo calc budget only holds for desktop chrome + ...(isNarrowViewport && + effectiveViewMode === 'table' && { + muiTableContainerProps: { + ...modeInfo.muiTableContainerProps, + sx: { + ...modeInfo.muiTableContainerProps?.sx, + maxHeight: narrowTableMaxHeight ? `${narrowTableMaxHeight}px` : 'none', + }, + }, + }), + renderRowActionMenuItems, + renderTopToolbar, + sortingFns: SORTING_FNS, + filterFns: FILTER_FNS, + globalFilterFn: 'contains', + enableGlobalFilterModes: true, + renderGlobalFilterModeMenuItems: renderGlobalFilterModeMenuItemsFn, + renderColumnFilterModeMenuItems: renderColumnFilterModeMenuItemsFn, + localization: MRT_LOCALIZATION_OVERRIDES, + muiSelectCheckboxProps: MUI_SELECT_CHECKBOX_PROPS, + muiSelectAllCheckboxProps: MUI_SELECT_ALL_CHECKBOX_PROPS, + muiFilterTextFieldProps: MUI_FILTER_TEXT_FIELD_PROPS, + }) + + // deselect all rows when the underlying data set actually changes, guarded so a toolbar + // remount (cards<->table flip) does not wipe an in-progress selection + const prevUsedDataRef = useRef(memoizedData) + useEffect(() => { + if (prevUsedDataRef.current === memoizedData) { + return + } + prevUsedDataRef.current = memoizedData + table.toggleAllRowsSelected(false) + }, [memoizedData]) + + // size the narrow table's scroll viewport from where it actually sits: viewport height + // minus the container's measured top, the real footer height and the chrome below the + // paper. the desktop calc assumes chrome heights that phone layouts do not have. + // deps include getRequestData.isSuccess so a cold load (table not yet mounted when the + // toggle flips tableViewActive true) re-arms the measurement once MRT actually renders + useEffect(() => { + if (!(isNarrowViewport && tableViewActive)) { + setNarrowTableMaxHeight(null) + return undefined + } + const measure = () => { + const container = table.refs.tableContainerRef?.current + if (!container) { + return + } + const footer = table.refs.bottomToolbarRef?.current?.offsetHeight ?? 0 + // chrome between the paper's bottom edge and the page bottom (CardContent padding + page gap) + const BELOW_PAPER_PX = 40 + // viewport-relative, the toggle handler aligns the card surface with the scroll + // viewport top first so this reads a deterministic position + const top = container.getBoundingClientRect().top + let next = Math.max(240, Math.floor(window.innerHeight - top - footer - BELOW_PAPER_PX)) + // 120 = minimal chrome allowance, keeps the table from claiming the full viewport + next = Math.min(next, window.innerHeight - 120) + setNarrowTableMaxHeight((prev) => { + if (prev !== null && Math.abs(prev - next) <= 1) { + return prev + } + return next + }) + } + const raf = requestAnimationFrame(() => requestAnimationFrame(measure)) + window.addEventListener('resize', measure) + let observer + // the paper mounts in the same commit as the container and the footer, so it is a + // reliable observation target even on the pass where the footer ref is still null + const paper = table.refs.tablePaperRef?.current + if (typeof ResizeObserver !== 'undefined' && paper) { + observer = new ResizeObserver(measure) + observer.observe(paper) + } + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', measure) + observer?.disconnect() + } + }, [isNarrowViewport, tableViewActive, table, getRequestData.isSuccess]) + + // A card shows at most a title, subtitle, three chips and three detail rows, so the rest + // of the row has to live in the drawer. On a page with no offCanvas that means every + // column the user has chosen to show; on a page that configured one, its curated fields + // come first and the remaining visible columns are appended rather than dropped — on + // desktop those columns are still on screen in the table, on mobile they are not. + const cardInfoFields = useMemo(() => { + if (!isCardView) return undefined + // A page that renders its own drawer body (offCanvas.children — the test-detail pages) + // is the authority on what the drawer shows; prepending the generic property list on + // top of it repeated Risk/Status above a body that already presents them. + if (offCanvas?.children) return undefined + const visible = table + .getVisibleLeafColumns() + .map((column) => column.id) + .filter((id) => !id.startsWith('mrt-')) + const curated = offCanvas?.extendedInfoFields + if (!curated?.length) return visible + // Curated order wins; dedupe case-insensitively across both lists, and within the + // curated list itself, so a field can't be shown twice. + const seen = new Set() + return [...curated, ...visible].filter((id) => { + if (typeof id !== 'string') return false + const key = id.toLowerCase() + if (seen.has(key)) return false + seen.add(key) + return true + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offCanvas, isCardView, table, columnVisibility, usedColumns]) + + // Applied after the {...offCanvas} spread below, which carries the page's own + // extendedInfoFields and would otherwise overwrite the merged list. Card view renders + // them the way the table cells do, since the appended entries are columns. + const cardInfoOverride = + isCardView && cardInfoFields?.length + ? { extendedInfoFields: cardInfoFields, richFormatting: true } + : {} + + // The rows the drawer's Prev/Next walks, in the order they are on screen. Read live from + // the table on every render: this used to be mirrored into state by an effect keyed on + // filters and sorting, so the copy was taken once at mount — before the rows had arrived + // — and every table that loads its data asynchronously reported nothing to navigate. + const navigationRows = table.getSortedRowModel().rows + // Prefer the position of the row actually on show, so sorting or filtering while the + // drawer is open carries the counter with it. The stored index covers the case where the + // row has left the list entirely — a background refetch replaces every object, so + // identity alone would strand the position — and is clamped so a list that shrank under + // it can't report "6 of 2". + const derivedRowIndex = offcanvasVisible + ? navigationRows.findIndex((row) => + rowsMatchContext(row.original, offCanvasData) + ) + : -1 + const currentRowIndex = + derivedRowIndex >= 0 + ? derivedRowIndex + : Math.min(offCanvasRowIndex, Math.max(navigationRows.length - 1, 0)) + + // Remove the useEffect that was resetting filters on table changes + // The initial filter application is now handled by the columnFilters state + // and the useEffect above that only triggers on actual filter prop changes + + // Exiting mobile select mode clears the selection — "Done" means done. + const handleMobileSelectModeChange = useCallback( + (on) => { + setMobileSelectMode(on) + if (!on) { + table.toggleAllRowsSelected(false) + } + }, + [table] + ) + + // Empty-state "Clear filters" in the card list. The full reset (graph filters, + // persisted slots) lives in the toolbar's filter sheet; this only clears what makes + // the current list empty. + const handleClearAllFilters = useCallback(() => { + table.resetGlobalFilter() + table.resetColumnFilters() + }, [table]) + + useEffect(() => { + if (onChange && table.getSelectedRowModel().rows) { + onChange(table.getSelectedRowModel().rows.map((row) => row.original)) + } + }, [table.getSelectedRowModel().rows]) + + useEffect(() => { + //check if the simplecolumns are an array, + if (Array.isArray(simpleColumns) && simpleColumns.length > 0) { + setConfiguredSimpleColumns(simpleColumns) + } + }, [simpleColumns]) + + const selectModeActive = hasOnChange ? true : mobileSelectMode + + const resolvedCardButton = cardButton ? ( + + ) : undefined + + // below md, table-in-Card branch: the actions FAB carries cardButton + const headerAction = isNarrowViewport && !isInDialog ? undefined : resolvedCardButton + + return ( + <> + {isCardView ? ( + // same paper surface as the table path; overflow visible keeps the controls bar sticky + + {!hideTitle && ( + + + {title} + + {Array.isArray(usedData) && !showSkeletons && ( + + {table.getFilteredRowModel().rows.length} results + + )} + + )} + {!Array.isArray(usedData) && usedData ? ( + + ) : ( + <> + + + + )} + {getRequestData.isError && !getRequestData.isFetchNextPageError && ( + getRequestData.refetch()} + message={`Error Loading data: ${getCippError(getRequestData.error)}`} + /> + )} + + ) : noCard ? ( + + {!Array.isArray(usedData) && usedData ? ( + + ) : ( + <> + {(getRequestData.isSuccess || + getRequestData.data?.pages.length >= 0 || + data) && } + + )} + {getRequestData.isError && !getRequestData.isFetchNextPageError && ( + getRequestData.refetch()} + message={`Error Loading data: ${getCippError(getRequestData.error)}`} + /> + )} + + ) : ( + // Render the table inside a Card + <> + + {cardButton || !hideTitle ? ( + <> + + + {/* narrow viewports carry these in the Table options sheet */} + {dataSourceControls && !isNarrowViewport ? ( + + {dataSourceControls} + {headerAction} + + ) : ( + headerAction + )} + + } + title={hideTitle ? '' : title} + {...props.cardHeaderProps} + /> + + + ) : null} + + + {!Array.isArray(usedData) && usedData ? ( + + ) : ( + <> + {(getRequestData.isSuccess || + getRequestData.data?.pages.length >= 0 || + (data && !getRequestData.isError)) && ( + + )} + + )} + {getRequestData.isError && + !getRequestData.isFetchNextPageError && ( + getRequestData.refetch()} + message={`Error Loading data: ${getCippError(getRequestData.error)}`} + /> + )} + + + + {isNarrowViewport && !isInDialog && resolvedCardButton && ( + {resolvedCardButton} + )} + + )} + + {rowContextMenuContent} + + offCanvas.children(row, currentRowIndex) + : undefined + } + customComponent={offCanvas?.customComponent} + onNavigateUp={() => { + const newIndex = currentRowIndex - 1 + if (newIndex >= 0 && navigationRows[newIndex]) { + const nextRow = navigationRows[newIndex].original + setOffCanvasRowIndex(newIndex) + setOffCanvasData(nextRow) + setContextRow(nextRow) + } + }} + onNavigateDown={() => { + const newIndex = currentRowIndex + 1 + if (navigationRows[newIndex]) { + const nextRow = navigationRows[newIndex].original + setOffCanvasRowIndex(newIndex) + setOffCanvasData(nextRow) + setContextRow(nextRow) + } + }} + canNavigateUp={currentRowIndex > 0} + canNavigateDown={currentRowIndex < navigationRows.length - 1} + navigationPosition={{ + index: currentRowIndex + 1, + total: navigationRows.length, + }} + {...offCanvas} + {...cardInfoOverride} + // Retired: the drawer's action buttons never dispatched reliably, and the row menu + // is the actions surface. Last so it beats page configs carrying offCanvas.actions. + actions={undefined} + /> + {/* Render custom component */} + {customComponentVisible && + customComponentData?.action && + typeof customComponentData.action.customComponent === 'function' && + customComponentData.action.customComponent(customComponentData.data, { + drawerVisible: customComponentVisible, + setDrawerVisible: setCustomComponentVisible, + fromRowAction: true, + })} + + {/* Render standard dialog */} + {useMemo(() => { + if ( + !actionData.ready || + (actionData.action && + typeof actionData.action.customComponent === 'function') + ) + return null + return ( + + ) + }, [ + actionData.ready, + createDialog, + actionData.action, + actionData.data, + queryKey, + title, + ])} + + ); +} diff --git a/src/components/CippTable/CippDataTableButton.jsx b/src/components/CippTable/CippDataTableButton.jsx index aa8702a17448..a7ffe1bdd676 100644 --- a/src/components/CippTable/CippDataTableButton.jsx +++ b/src/components/CippTable/CippDataTableButton.jsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Dialog, DialogContent, DialogTitle, IconButton, Button, useMediaQuery } from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; import { CippDataTable } from "./CippDataTable"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { resolveRowTemplates, getRowTenant } from "../../utils/resolve-row-templates"; @@ -151,7 +151,7 @@ const CippDataTableButton = ({ aria-label="Close" sx={{ minWidth: 44, minHeight: 44 }} > - + {dialogTitle} @@ -181,7 +181,7 @@ const CippDataTableButton = ({ aria-label="Close" sx={{ minWidth: 44, minHeight: 44 }} > - + {tableTitle} diff --git a/src/components/CippTable/CippDiagnosticsFilter.js b/src/components/CippTable/CippDiagnosticsFilter.js deleted file mode 100644 index 3eeb44cf002f..000000000000 --- a/src/components/CippTable/CippDiagnosticsFilter.js +++ /dev/null @@ -1,308 +0,0 @@ -import { useState, useEffect } from "react"; -import { useForm, useWatch } from "react-hook-form"; -import { - Box, - Button, - Stack, - Alert, - AlertTitle, - Accordion, - AccordionSummary, - AccordionDetails, - Typography, - IconButton, - Tooltip, - CircularProgress, -} from "@mui/material"; -import { ExpandMore, Search, Save, Delete } from "@mui/icons-material"; -import { CippFormComponent } from "../CippComponents/CippFormComponent"; -import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; -import { Grid } from "@mui/system"; -import defaultPresets from "../../data/DiagnosticsPresets.json"; -import { useIsMobileLayout } from "../../hooks/use-breakpoint"; - -const CippDiagnosticsFilter = ({ onSubmitFilter }) => { - // A 12-row monospace query box is roughly half a phone viewport before anything else. - const isMobile = useIsMobileLayout(); - const [expanded, setExpanded] = useState(true); - const [selectedPreset, setSelectedPreset] = useState(null); - const [presetOptions, setPresetOptions] = useState([]); - - const formControl = useForm({ - mode: "onChange", - defaultValues: { - presetName: "", - queryPreset: null, - query: "", - }, - }); - - const { handleSubmit } = formControl; - const queryValue = useWatch({ control: formControl.control, name: "query" }); - const queryPreset = useWatch({ control: formControl.control, name: "queryPreset" }); - const presetName = useWatch({ control: formControl.control, name: "presetName" }); - - // Load presets - const presetList = ApiGetCall({ - url: "/api/ListDiagnosticsPresets", - queryKey: "ListDiagnosticsPresets", - }); - - useEffect(() => { - // Combine built-in presets with custom presets - const builtInOptions = defaultPresets.map((preset) => ({ - label: preset.name, - value: preset.id, - query: preset.query, - columns: preset.columns || null, - isBuiltin: true, - })); - - const customOptions = - presetList.isSuccess && presetList.data - ? presetList.data.map((preset) => ({ - label: preset.name, - value: preset.GUID, - query: preset.query, - isBuiltin: false, - })) - : []; - - setPresetOptions([...builtInOptions, ...customOptions]); - }, [presetList.isSuccess, presetList.data]); - - // Load preset when selected - useEffect(() => { - if (queryPreset) { - // queryPreset is the full object from autoComplete - // Check if it's an array (multiple) or object (single) - const preset = Array.isArray(queryPreset) ? queryPreset[0] : queryPreset; - - if (preset?.query) { - formControl.setValue("query", preset.query); - formControl.setValue("presetName", preset.label); - setSelectedPreset(preset); - // Clear the preset selection so user can edit freely - formControl.setValue("queryPreset", null); - } - } - }, [queryPreset, formControl]); - - // Clear selectedPreset when query is manually edited (unless preset is custom or has no columns) - useEffect(() => { - if (selectedPreset && queryValue !== selectedPreset.query) { - // Only clear if preset is built-in and has columns defined - if (selectedPreset.isBuiltin && selectedPreset.columns) { - setSelectedPreset(null); - } - } - }, [queryValue, selectedPreset]); - - const savePresetApi = ApiPostCall({ - relatedQueryKeys: ["ListDiagnosticsPresets"], - }); - - const deletePresetApi = ApiPostCall({ - relatedQueryKeys: ["ListDiagnosticsPresets"], - }); - - const handleSavePreset = () => { - if (!presetName || !queryValue) { - return; - } - - // Built-in presets get saved as new custom presets (no GUID = new preset) - // Custom presets can be updated (include GUID) - const presetData = { - name: presetName, - query: queryValue, - GUID: selectedPreset?.isBuiltin ? undefined : selectedPreset?.value || undefined, - }; - - const isUpdate = selectedPreset && !selectedPreset.isBuiltin; - - savePresetApi.mutate({ - url: "/api/ExecDiagnosticsPresets", - data: presetData, - title: isUpdate ? "Update Preset" : "Save Preset", - message: isUpdate - ? `Preset "${presetName}" updated successfully` - : `Preset "${presetName}" saved successfully`, - }); - }; - - const handleDeletePreset = () => { - if (!selectedPreset || selectedPreset.isBuiltin) { - return; - } - - deletePresetApi.mutate({ - url: "/api/ExecDiagnosticsPresets", - data: { - GUID: selectedPreset.value, - action: "delete", - }, - title: "Delete Preset", - message: `Preset "${selectedPreset.label}" deleted successfully`, - }); - - formControl.setValue("queryPreset", null); - formControl.setValue("presetName", ""); - setSelectedPreset(null); - }; - - const onSubmit = (values) => { - if (values.query && values.query.trim()) { - onSubmitFilter({ - ...values, - presetDisplayName: values.presetName || selectedPreset?.label || null, - columns: selectedPreset?.columns || null, - }); - setExpanded(false); - } - }; - - const handleClear = () => { - formControl.reset({ query: "", presetName: "", queryPreset: null }); - onSubmitFilter({ query: "", presetDisplayName: null, columns: null }); - // Only clear selectedPreset if it's a built-in preset - // Keep custom preset reference so user can continue editing and saving - if (selectedPreset?.isBuiltin) { - setSelectedPreset(null); - } - setExpanded(true); - }; - - return ( - setExpanded(!expanded)}> - }> - Query - - - - - Requirements - - • Application Insights must be deployed for your CIPP environment -
    • The Function App's managed identity must have Reader{" "} - permissions on the Application Insights resource -
    • Queries are executed using Kusto Query Language (KQL) -
    -
    - - - - - - - - - - - - - - - - - - {savePresetApi.isPending ? : } - - - - - - - {deletePresetApi.isPending ? ( - - ) : ( - - )} - - - - - - - - - - ago(1h)\n| where severityLevel >= 2\n| project timestamp, message, severityLevel\n| order by timestamp desc`} - helperText="Enter a valid Kusto Query Language (KQL) query to execute against Application Insights" - sx={{ - "& textarea": { - fontFamily: "monospace", - fontSize: "0.875rem", - }, - }} - /> - - - - - - - -
    -
    -
    - ); -}; - -export default CippDiagnosticsFilter; diff --git a/src/components/CippTable/CippDiagnosticsFilter.jsx b/src/components/CippTable/CippDiagnosticsFilter.jsx new file mode 100644 index 000000000000..cea4a4806a78 --- /dev/null +++ b/src/components/CippTable/CippDiagnosticsFilter.jsx @@ -0,0 +1,312 @@ +import { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry" +import { useForm, useWatch } from "react-hook-form"; +import { + Box, + Button, + Stack, + Alert, + AlertTitle, + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + IconButton, + Tooltip, + CircularProgress, +} from "@mui/material"; +import { CippFormComponent } from "../CippComponents/CippFormComponent"; +import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; +import { Grid } from "@mui/system"; +import defaultPresets from "../../data/DiagnosticsPresets.json"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; + +const CippDiagnosticsFilter = ({ onSubmitFilter }) => { + // A 12-row monospace query box is roughly half a phone viewport before anything else. + const isMobile = useIsMobileLayout(); + const [expanded, setExpanded] = useState(true); + const [selectedPreset, setSelectedPreset] = useState(null); + const [presetOptions, setPresetOptions] = useState([]); + + const formControl = useForm({ + mode: "onChange", + defaultValues: { + presetName: "", + queryPreset: null, + query: "", + }, + }); + + const { handleSubmit } = formControl; + const queryValue = useWatch({ control: formControl.control, name: "query" }); + const queryPreset = useWatch({ control: formControl.control, name: "queryPreset" }); + const presetName = useWatch({ control: formControl.control, name: "presetName" }); + + // Load presets + const presetList = ApiGetCall({ + url: "/api/ListDiagnosticsPresets", + queryKey: "ListDiagnosticsPresets", + }); + + useEffect(() => { + // Combine built-in presets with custom presets + const builtInOptions = defaultPresets.map((preset) => ({ + label: preset.name, + value: preset.id, + query: preset.query, + columns: preset.columns || null, + isBuiltin: true, + })); + + const customOptions = + presetList.isSuccess && presetList.data + ? presetList.data.map((preset) => ({ + label: preset.name, + value: preset.GUID, + query: preset.query, + isBuiltin: false, + })) + : []; + + setPresetOptions([...builtInOptions, ...customOptions]); + }, [presetList.isSuccess, presetList.data]); + + // Load preset when selected + useEffect(() => { + if (queryPreset) { + // queryPreset is the full object from autoComplete + // Check if it's an array (multiple) or object (single) + const preset = Array.isArray(queryPreset) ? queryPreset[0] : queryPreset; + + if (preset?.query) { + formControl.setValue("query", preset.query); + formControl.setValue("presetName", preset.label); + setSelectedPreset(preset); + // Clear the preset selection so user can edit freely + formControl.setValue("queryPreset", null); + } + } + }, [queryPreset, formControl]); + + // Clear selectedPreset when query is manually edited (unless preset is custom or has no columns) + useEffect(() => { + if (selectedPreset && queryValue !== selectedPreset.query) { + // Only clear if preset is built-in and has columns defined + if (selectedPreset.isBuiltin && selectedPreset.columns) { + setSelectedPreset(null); + } + } + }, [queryValue, selectedPreset]); + + const savePresetApi = ApiPostCall({ + relatedQueryKeys: ["ListDiagnosticsPresets"], + }); + + const deletePresetApi = ApiPostCall({ + relatedQueryKeys: ["ListDiagnosticsPresets"], + }); + + const handleSavePreset = () => { + if (!presetName || !queryValue) { + return; + } + + // Built-in presets get saved as new custom presets (no GUID = new preset) + // Custom presets can be updated (include GUID) + const presetData = { + name: presetName, + query: queryValue, + GUID: selectedPreset?.isBuiltin ? undefined : selectedPreset?.value || undefined, + }; + + const isUpdate = selectedPreset && !selectedPreset.isBuiltin; + + savePresetApi.mutate({ + url: "/api/ExecDiagnosticsPresets", + data: presetData, + title: isUpdate ? "Update Preset" : "Save Preset", + message: isUpdate + ? `Preset "${presetName}" updated successfully` + : `Preset "${presetName}" saved successfully`, + }); + }; + + const handleDeletePreset = () => { + if (!selectedPreset || selectedPreset.isBuiltin) { + return; + } + + deletePresetApi.mutate({ + url: "/api/ExecDiagnosticsPresets", + data: { + GUID: selectedPreset.value, + action: "delete", + }, + title: "Delete Preset", + message: `Preset "${selectedPreset.label}" deleted successfully`, + }); + + formControl.setValue("queryPreset", null); + formControl.setValue("presetName", ""); + setSelectedPreset(null); + }; + + const onSubmit = (values) => { + if (values.query && values.query.trim()) { + onSubmitFilter({ + ...values, + presetDisplayName: values.presetName || selectedPreset?.label || null, + columns: selectedPreset?.columns || null, + }); + setExpanded(false); + } + }; + + const handleClear = () => { + formControl.reset({ query: "", presetName: "", queryPreset: null }); + onSubmitFilter({ query: "", presetDisplayName: null, columns: null }); + // Only clear selectedPreset if it's a built-in preset + // Keep custom preset reference so user can continue editing and saving + if (selectedPreset?.isBuiltin) { + setSelectedPreset(null); + } + setExpanded(true); + }; + + return ( + setExpanded(!expanded)}> + }> + Query + + + + + Requirements + + • Application Insights must be deployed for your CIPP environment +
    • The Function App's managed identity must have Reader{" "} + permissions on the Application Insights resource +
    • Queries are executed using Kusto Query Language (KQL) +
    +
    + + + + + + + + + + + + + + + + + + {savePresetApi.isPending ? : } + + + + + + + {deletePresetApi.isPending ? ( + + ) : ( + + )} + + + + + + + + + + ago(1h)\n| where severityLevel >= 2\n| project timestamp, message, severityLevel\n| order by timestamp desc`} + helperText="Enter a valid Kusto Query Language (KQL) query to execute against Application Insights" + sx={{ + "& textarea": { + fontFamily: "monospace", + fontSize: "0.875rem", + }, + }} + /> + + + + + + + +
    +
    +
    + ); +}; + +export default CippDiagnosticsFilter; diff --git a/src/components/CippTable/CippGraphExplorerFilter.js b/src/components/CippTable/CippGraphExplorerFilter.js deleted file mode 100644 index 19ebbb479d34..000000000000 --- a/src/components/CippTable/CippGraphExplorerFilter.js +++ /dev/null @@ -1,940 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { Box, Button, Link, Typography } from '@mui/material' -import { - Save as SaveIcon, - Delete, - CalendarMonthTwoTone, - CopyAll, - ImportExport, - PlayCircle, -} from '@mui/icons-material' -import { useForm, useWatch } from 'react-hook-form' -import { debounce } from 'lodash' -import CippButtonCard from '../CippCards/CippButtonCard' -import CippFormComponent from '../CippComponents/CippFormComponent' -import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' -import { useSettings } from '../../hooks/use-settings' -import { CippApiResults } from '../CippComponents/CippApiResults' -import { CippFormCondition } from '../CippComponents/CippFormCondition' -import { CippOffCanvas } from '../CippComponents/CippOffCanvas' -import { CippCodeBlock } from '../CippComponents/CippCodeBlock' -import CippSchedulerForm from '../CippFormPages/CippSchedulerForm' -import defaultPresets from '../../data/GraphExplorerPresets' -import { Grid, Stack } from '@mui/system' -import { GroupHeader, GroupItems } from '../CippComponents/CippAutocompleteGrouping' - -const CippGraphExplorerFilter = ({ - endpointFilter = '', - onSubmitFilter, - onPresetChange, - component = 'accordion', - relatedQueryKeys = [], - selectedPreset = null, - onPresetSelect, - hideButtons = false, -}) => { - const [offCanvasOpen, setOffCanvasOpen] = useState(false) - const [cardExpanded, setCardExpanded] = useState(true) - const [offCanvasContent, setOffCanvasContent] = useState(null) - const [selectedPresetState, setSelectedPreset] = useState(null) - const [presetOwner, setPresetOwner] = useState(false) - const [lastPresetTitle, setLastPresetTitle] = useState(null) - const [presetOptions, setPresetOptions] = useState([]) - const formControl = useForm({ - mode: 'onChange', - defaultValues: { - endpoint: '', - version: { label: 'beta', value: 'beta' }, - $select: [], - $filter: '', - $expand: '', - $top: '', - $search: '', - $orderby: '', - $format: '', - NoPagination: false, - ReverseTenantLookup: false, - ReverseTenantLookupProperty: 'tenantId', - $count: false, - manualPagination: false, - IsShared: false, - }, - }) - const presetControl = useForm({ - mode: 'onChange', - defaultValues: { - reportTemplate: null, - }, - }) - - const defaultGraphExplorerTitle = 'Graph Explorer' - - // Accordion (the Graph Explorer page) lays fields out two-up; the card variant used in - // the table filter drawer is single-column. Either way a phone gets one column. - var gridItemSize = { xs: 12, md: 6 } - if (component !== 'accordion') { - gridItemSize = 12 - } - - var gridSwitchSize = 3 - if (component !== 'accordion') { - gridSwitchSize = 12 - } - - const [currentEndpoint, setCurrentEndpoint] = useState(endpointFilter) - const { control, handleSubmit } = formControl - const tenant = useSettings().currentTenant - const watchedValues = useWatch({ control: formControl.control }) - - useEffect(() => { - const endpoint = watchedValues.endpoint - if (endpoint && endpoint !== currentEndpoint) { - setCurrentEndpoint(endpoint) - } - }, [watchedValues.endpoint]) - - // API call for available properties - const propertyList = ApiGetCall({ - url: '/api/ListGraphRequest', - queryKey: `graph-properties-${currentEndpoint}`, - data: { - Endpoint: currentEndpoint, - ListProperties: true, - TenantFilter: tenant, - IgnoreErrors: true, - }, - waiting: false, - }) - - // Seeding the form from the endpointFilter prop is a side effect: doing it during render - // updates the subscribed Controller mid-render ("Cannot update a component while rendering - // a different component"). It fires twice on mobile, where the table remounts as cards. - useEffect(() => { - if (endpointFilter && formControl.getValues('endpoint') !== endpointFilter) { - formControl.setValue('endpoint', endpointFilter) - } - }, [endpointFilter, formControl]) - - const presetFilter = endpointFilter ? { Endpoint: endpointFilter } : {} - - // API call for available presets - const presetList = ApiGetCall({ - url: '/api/ListGraphExplorerPresets', - queryKey: 'ListGraphExplorerPresets', - data: presetFilter, - }) - - useEffect(() => { - var presetOptionList = [] - const normalizeEndpoint = (endpoint) => endpoint.replace(/^\//, '') - defaultPresets - .filter( - (item) => - !endpointFilter || - normalizeEndpoint(item.params.endpoint) === normalizeEndpoint(endpointFilter) - ) - .forEach((item) => { - presetOptionList.push({ - label: item.name, - value: item.id, - addedFields: item, - type: 'Built-In', - }) - }) - if (presetList.isSuccess && presetList.data?.Results.length > 0) { - presetList.data.Results.forEach((item) => { - presetOptionList.push({ - label: item.name, - value: item.id, - addedFields: item, - type: 'Custom', - }) - }) - } - setPresetOptions(presetOptionList) - }, [defaultPresets, presetList.isSuccess, presetList.data]) - - // Debounced refetch when endpoint, put in in a useEffect dependand on endpoint - const debouncedRefetch = useCallback( - debounce(() => { - if (currentEndpoint) { - propertyList.refetch() - } - }, 1000), - [currentEndpoint] // Dependencies that the debounce function depends on - ) - - useEffect(() => { - debouncedRefetch() - // Clean up the debounce on unmount - return () => { - debouncedRefetch.cancel() - } - }, [currentEndpoint, debouncedRefetch]) - - const savePresetApi = ApiPostCall({ - relatedQueryKeys: ['ListGraphExplorerPresets*', 'ListGraphRequest', ...relatedQueryKeys], - }) - - // Save preset function - const handleSavePreset = () => { - const currentTemplate = formControl.getValues() - if (!presetOwner && currentTemplate?.id) { - delete currentTemplate.id - } - savePresetApi.mutate({ - url: '/api/ExecGraphExplorerPreset', - data: { action: presetOwner ? 'Save' : 'Copy', preset: currentTemplate }, - }) - } - - const deletePreset = (id) => { - savePresetApi.mutate({ - url: '/api/ExecGraphExplorerPreset', - data: { action: 'Delete', preset: { id: selectedPresetState } }, - }) - } - - const selectedPresets = useWatch({ control: presetControl.control, name: 'reportTemplate' }) - - // Sync with parent component's selected preset - // Handles two shapes: - // - Autocomplete option: { label, value: id, addedFields: { params } } - // - Toolbar filter item: { id, filterName, value: params, type } - useEffect(() => { - if (!selectedPreset) return - - let option = selectedPreset - - if (!selectedPreset.addedFields) { - // Toolbar format — look up matching option in presetOptions by id first - const found = presetOptions.find((o) => o.value === selectedPreset.id) - option = found ?? { - label: selectedPreset.filterName, - value: selectedPreset.id, - addedFields: { - id: selectedPreset.id, - name: selectedPreset.filterName, - params: selectedPreset.value, - }, - type: selectedPreset.type, - } - } - - if (option.value !== selectedPresets?.value) { - presetControl.setValue('reportTemplate', option) - } - // selectedPreset?.value covers the autocomplete option shape (no id/filterName keys) - }, [selectedPreset?.id, selectedPreset?.filterName, selectedPreset?.value, presetOptions]) - - useEffect(() => { - if (selectedPresets?.addedFields?.params) { - setPresetOwner(selectedPresets?.addedFields?.IsMyPreset ?? false) - - // normalize on a copy, addedFields.params is shared state (defaultPresets module / react-query cache) - const params = { ...selectedPresets.addedFields.params } - Object.keys(params).forEach((key) => { - if (params[key] == null) { - delete params[key] - } - }) - - // $select arrives as comma string (built-in presets) or array (saved presets), form needs [{label,value}] - if (Array.isArray(params.$select)) { - params.$select = params.$select.map((item) => - typeof item === 'string' ? { label: item, value: item } : item - ) - } else if (typeof params.$select === 'string' && params.$select !== '') { - params.$select = params.$select.split(',').map((item) => ({ label: item, value: item })) - } else { - params.$select = [] - } - - // Convert version string to autocomplete object format, default to beta if not present - if (params.version) { - const versionValue = - typeof params.version === 'string' ? params.version : params.version.value - params.version = { label: versionValue, value: versionValue } - } else { - params.version = { label: 'beta', value: 'beta' } - } - - params.id = selectedPresets.value - setSelectedPreset(selectedPresets.value) - params.name = selectedPresets.label - - // save last preset title - setLastPresetTitle(selectedPresets.label) - formControl.reset(params, { keepDefaultValues: true }) - - // Notify parent when preset changes in this component - if (onPresetSelect) { - onPresetSelect(selectedPresets) - } - } - }, [selectedPresets]) - - const schedulerForm = useForm({ - mode: 'onChange', - }) - - const schedulerCommand = { - Function: 'Get-GraphRequestList', - Synopsis: 'Execute a Graph query', - Parameters: [ - { - Name: 'Endpoint', - Type: 'System.String', - Description: 'Graph API endpoint', - Required: true, - }, - { - Name: 'Parameters', - Type: 'System.Collections.Hashtable', - Description: 'API Parameters', - Required: false, - }, - { - Name: 'queueId', - Type: 'System.String', - Description: 'Queue Id', - Required: false, - }, - { - Name: 'NoPagination', - Type: 'System.Management.Automation.SwitchParameter', - Description: 'Disable pagination', - Required: false, - }, - { - Name: 'CountOnly', - Type: 'System.Management.Automation.SwitchParameter', - Description: 'Only return count of results', - Required: false, - }, - { - Name: 'ReverseTenantLookup', - Type: 'System.Management.Automation.SwitchParameter', - Description: 'Perform reverse tenant lookup', - Required: false, - }, - { - Name: 'ReverseTenantLookupProperty', - Type: 'System.String', - Description: 'Property to perform reverse tenant lookup', - Required: false, - }, - { - Name: 'AsApp', - Type: 'System.Boolean', - Description: null, - Required: false, - }, - ], - } - // Schedule report function - const handleScheduleReport = () => { - const formParameters = watchedValues - const selectString = formParameters.$select - ? formParameters.$select?.map((item) => item.value).join(',') - : null - - //compose the parameters for the form based on what is available - var Parameters = [ - { - Key: '$select', - Value: selectString, - }, - { - Key: '$filter', - Value: formParameters.$filter, - }, - { - Key: '$top', - Value: formParameters.$top, - }, - { - Key: '$search', - Value: formParameters.$search, - }, - { - Key: '$count', - Value: formParameters.$count, - }, - { - Key: '$expand', - Value: formParameters.$expand, - }, - { - Key: '$orderby', - Value: formParameters.$orderby, - }, - { - Key: '$format', - Value: formParameters.$format, - }, - ] - Parameters = Parameters.filter((param) => { - return ( - param.Value != null && - param.Value !== '' && - !(typeof param.Value === 'boolean' && param.Value === false) - ) - }) - const resetParams = { - tenantFilter: tenant, - Name: formParameters.name - ? `Graph Explorer - ${formParameters.name}` - : 'Graph Explorer Report', - command: { - label: schedulerCommand.Function, - value: schedulerCommand.Function, - addedFields: schedulerCommand, - }, - parameters: { - Endpoint: formParameters.endpoint, - skipCache: true, - NoPagination: formParameters.NoPagination, - AsApp: formParameters.AsApp, - ReverseTenantLookup: formParameters.ReverseTenantLookup, - ReverseTenantLookupProperty: formParameters.ReverseTenantLookupProperty, - Parameters: Parameters, - }, - advancedParameters: false, - Recurrence: { - value: 0, - label: 'Only once', - }, - } - schedulerForm.reset(resetParams) - setOffCanvasContent( - <> - - Schedule Graph Explorer Report - - - - ) - setOffCanvasOpen(true) - } - - const [editorValues, setEditorValues] = useState({}) - //keep the editor in sync with the form - - function getPresetProps(values) { - var newvals = Object.assign({}, values) - if (newvals?.$select !== undefined && Array.isArray(newvals?.$select)) { - newvals.$select = newvals?.$select.map((p) => p.value).join(',') - } - if (newvals.version && newvals.version.value) { - newvals.version = newvals.version.value - } else if (!newvals.version) { - newvals.version = 'beta' - } - delete newvals['reportTemplate'] - delete newvals['tenantFilter'] - delete newvals['IsShared'] - delete newvals['id'] - if (newvals.ReverseTenantLookup === false) { - delete newvals.ReverseTenantLookup - } - if (newvals.NoPagination === false) { - delete newvals.NoPagination - } - if (newvals.$count === false) { - delete newvals.$count - } - if (newvals.AsApp === false) { - delete newvals.AsApp - } - Object.keys(newvals).forEach((key) => { - if (values[key] === '' || values[key] === null) { - delete newvals[key] - } - }) - return newvals - } - - useEffect(() => { - var values = getPresetProps(watchedValues) - setOffCanvasContent(() => ( - <> - - Import / Export Graph Explorer Preset - - - Copy the JSON below to export your preset, or paste a preset JSON to import it. - - setEditorValues(JSON.parse(value))} - code={JSON.stringify(values, null, 2)} - /> - - - - )) - }, [editorValues, savePresetApi.isPending, formControl, selectedPresets, watchedValues]) - - const handleImport = () => { - setOffCanvasOpen(true) // Open the offCanvas, the content will be updated by useEffect - } - // Handle filter form submission - const onSubmit = (values) => { - if (Array.isArray(values.$select)) { - // empty array joins to '', removed by the null/empty cleanup below - values.$select = values.$select.map((item) => item.value).join(',') - } - if (values.version && values.version.value) { - values.version = values.version.value - } else if (!values.version) { - values.version = 'beta' - } - if (values.ReverseTenantLookup === false) { - delete values.ReverseTenantLookup - } - if (values.NoPagination === false) { - delete values.NoPagination - } - if (values.$count === false) { - delete values.$count - } - if (values.AsApp === false) { - delete values.AsApp - } - - // Remove non-API fields and null/empty values - delete values.name - delete values.id - delete values.IsShared - delete values.reportTemplate - delete values.manualPagination - if (!values.ReverseTenantLookup) { - // property only means something with the lookup enabled - delete values.ReverseTenantLookupProperty - } - - Object.keys(values).forEach((key) => { - if (values[key] === null || values[key] === '') { - delete values[key] - } - }) - - if (onPresetChange) { - const presetName = lastPresetTitle ? `Graph Explorer - ${lastPresetTitle}` : null - if (presetName) onPresetChange(presetName) - } - onSubmitFilter(values) - } - - //console.log(cardExpanded); - return ( -
    - setCardExpanded(expanded)} - cardSx={{ - width: '100%', - height: '100%', - mb: 2, - }} - > - - - option.type} - renderGroup={(params) => ( -
  • - {params.group} - {params.children} -
  • - )} - placeholder="Select a preset" - helperText="Select an existing preset to load its parameters" - /> -
    - - {/* Preset Name Field */} - - - - - - - The{' '} - - Graph endpoint - {' '} - to query (e.g. https://graph.microsoft.com/beta/$Endpoint) - - } - /> - - - - - - - - 0 && - propertyList?.data?.Results?.map((item) => ({ label: item, value: item }))) || [ - { - label: 'No properties found, check your endpoint', - value: '', - }, - ] - } - placeholder="Columns to select" - helperText="List of object properties to include in the response" - /> - - - {/* Filter Field */} - - - Graph $filter query - - } - /> - - - {/* Expand Field */} - - - - - {/* Top Field */} - - - - - {/* Search Field */} - - - - - {/* OrderBy Field */} - - - - - {/* Format Field */} - - - -
    - - {/* Reverse Tenant Lookup Switch */} - - - - - {/* Reverse Tenant Lookup Property Field */} - - - - - {/* No Pagination Switch */} - - - - {/* $count Switch */} - - - - - {/* AsApp switch */} - - - - - - {/* Footer-style action section */} - {!hideButtons && ( - - - - {component === 'accordion' ? ( - - - - - - - - - - - - - - - ) : ( - - - - - - - - - - - - - - - - - - - - - - - )} - - - )} -
    - setOffCanvasOpen(false)} - children={offCanvasContent} - /> - - ) -} - -export default CippGraphExplorerFilter diff --git a/src/components/CippTable/CippGraphExplorerFilter.jsx b/src/components/CippTable/CippGraphExplorerFilter.jsx new file mode 100644 index 000000000000..eba6c6cbf83b --- /dev/null +++ b/src/components/CippTable/CippGraphExplorerFilter.jsx @@ -0,0 +1,933 @@ +import { useState, useEffect, useCallback } from 'react' +import { CippIcons } from '../../utils/icon-registry' +import { Box, Button, Link, Typography } from '@mui/material' +import { useForm, useWatch } from 'react-hook-form' +import { debounce } from 'lodash' +import CippButtonCard from '../CippCards/CippButtonCard' +import CippFormComponent from '../CippComponents/CippFormComponent' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' +import { CippApiResults } from '../CippComponents/CippApiResults' +import { CippFormCondition } from '../CippComponents/CippFormCondition' +import { CippOffCanvas } from '../CippComponents/CippOffCanvas' +import { CippCodeBlock } from '../CippComponents/CippCodeBlock' +import CippSchedulerForm from '../CippFormPages/CippSchedulerForm' +import defaultPresets from '../../data/GraphExplorerPresets' +import { Grid, Stack } from '@mui/system' +import { GroupHeader, GroupItems } from '../CippComponents/CippAutocompleteGrouping' + +const CippGraphExplorerFilter = ({ + endpointFilter = '', + onSubmitFilter, + onPresetChange, + component = 'accordion', + relatedQueryKeys = [], + selectedPreset = null, + onPresetSelect, + hideButtons = false, +}) => { + const [offCanvasOpen, setOffCanvasOpen] = useState(false) + const [cardExpanded, setCardExpanded] = useState(true) + const [offCanvasContent, setOffCanvasContent] = useState(null) + const [selectedPresetState, setSelectedPreset] = useState(null) + const [presetOwner, setPresetOwner] = useState(false) + const [lastPresetTitle, setLastPresetTitle] = useState(null) + const [presetOptions, setPresetOptions] = useState([]) + const formControl = useForm({ + mode: 'onChange', + defaultValues: { + endpoint: '', + version: { label: 'beta', value: 'beta' }, + $select: [], + $filter: '', + $expand: '', + $top: '', + $search: '', + $orderby: '', + $format: '', + NoPagination: false, + ReverseTenantLookup: false, + ReverseTenantLookupProperty: 'tenantId', + $count: false, + manualPagination: false, + IsShared: false, + }, + }) + const presetControl = useForm({ + mode: 'onChange', + defaultValues: { + reportTemplate: null, + }, + }) + + const defaultGraphExplorerTitle = 'Graph Explorer' + + // Accordion (the Graph Explorer page) lays fields out two-up; the card variant used in + // the table filter drawer is single-column. Either way a phone gets one column. + var gridItemSize = { xs: 12, md: 6 } + if (component !== 'accordion') { + gridItemSize = 12 + } + + var gridSwitchSize = 3 + if (component !== 'accordion') { + gridSwitchSize = 12 + } + + const [currentEndpoint, setCurrentEndpoint] = useState(endpointFilter) + const { control, handleSubmit } = formControl + const tenant = useSettings().currentTenant + const watchedValues = useWatch({ control: formControl.control }) + + useEffect(() => { + const endpoint = watchedValues.endpoint + if (endpoint && endpoint !== currentEndpoint) { + setCurrentEndpoint(endpoint) + } + }, [watchedValues.endpoint]) + + // API call for available properties + const propertyList = ApiGetCall({ + url: '/api/ListGraphRequest', + queryKey: `graph-properties-${currentEndpoint}`, + data: { + Endpoint: currentEndpoint, + ListProperties: true, + TenantFilter: tenant, + IgnoreErrors: true, + }, + waiting: false, + }) + + // Seeding the form from the endpointFilter prop is a side effect: doing it during render + // updates the subscribed Controller mid-render ("Cannot update a component while rendering + // a different component"). It fires twice on mobile, where the table remounts as cards. + useEffect(() => { + if (endpointFilter && formControl.getValues('endpoint') !== endpointFilter) { + formControl.setValue('endpoint', endpointFilter) + } + }, [endpointFilter, formControl]) + + const presetFilter = endpointFilter ? { Endpoint: endpointFilter } : {} + + // API call for available presets + const presetList = ApiGetCall({ + url: '/api/ListGraphExplorerPresets', + queryKey: 'ListGraphExplorerPresets', + data: presetFilter, + }) + + useEffect(() => { + var presetOptionList = [] + const normalizeEndpoint = (endpoint) => endpoint.replace(/^\//, '') + defaultPresets + .filter( + (item) => + !endpointFilter || + normalizeEndpoint(item.params.endpoint) === normalizeEndpoint(endpointFilter) + ) + .forEach((item) => { + presetOptionList.push({ + label: item.name, + value: item.id, + addedFields: item, + type: 'Built-In', + }) + }) + if (presetList.isSuccess && presetList.data?.Results.length > 0) { + presetList.data.Results.forEach((item) => { + presetOptionList.push({ + label: item.name, + value: item.id, + addedFields: item, + type: 'Custom', + }) + }) + } + setPresetOptions(presetOptionList) + }, [defaultPresets, presetList.isSuccess, presetList.data]) + + // Debounced refetch when endpoint, put in in a useEffect dependand on endpoint + const debouncedRefetch = useCallback( + debounce(() => { + if (currentEndpoint) { + propertyList.refetch() + } + }, 1000), + [currentEndpoint] // Dependencies that the debounce function depends on + ) + + useEffect(() => { + debouncedRefetch() + // Clean up the debounce on unmount + return () => { + debouncedRefetch.cancel() + } + }, [currentEndpoint, debouncedRefetch]) + + const savePresetApi = ApiPostCall({ + relatedQueryKeys: ['ListGraphExplorerPresets*', 'ListGraphRequest', ...relatedQueryKeys], + }) + + // Save preset function + const handleSavePreset = () => { + const currentTemplate = formControl.getValues() + if (!presetOwner && currentTemplate?.id) { + delete currentTemplate.id + } + savePresetApi.mutate({ + url: '/api/ExecGraphExplorerPreset', + data: { action: presetOwner ? 'Save' : 'Copy', preset: currentTemplate }, + }) + } + + const deletePreset = (id) => { + savePresetApi.mutate({ + url: '/api/ExecGraphExplorerPreset', + data: { action: 'Delete', preset: { id: selectedPresetState } }, + }) + } + + const selectedPresets = useWatch({ control: presetControl.control, name: 'reportTemplate' }) + + // Sync with parent component's selected preset + // Handles two shapes: + // - Autocomplete option: { label, value: id, addedFields: { params } } + // - Toolbar filter item: { id, filterName, value: params, type } + useEffect(() => { + if (!selectedPreset) return + + let option = selectedPreset + + if (!selectedPreset.addedFields) { + // Toolbar format — look up matching option in presetOptions by id first + const found = presetOptions.find((o) => o.value === selectedPreset.id) + option = found ?? { + label: selectedPreset.filterName, + value: selectedPreset.id, + addedFields: { + id: selectedPreset.id, + name: selectedPreset.filterName, + params: selectedPreset.value, + }, + type: selectedPreset.type, + } + } + + if (option.value !== selectedPresets?.value) { + presetControl.setValue('reportTemplate', option) + } + // selectedPreset?.value covers the autocomplete option shape (no id/filterName keys) + }, [selectedPreset?.id, selectedPreset?.filterName, selectedPreset?.value, presetOptions]) + + useEffect(() => { + if (selectedPresets?.addedFields?.params) { + setPresetOwner(selectedPresets?.addedFields?.IsMyPreset ?? false) + + // normalize on a copy, addedFields.params is shared state (defaultPresets module / react-query cache) + const params = { ...selectedPresets.addedFields.params } + Object.keys(params).forEach((key) => { + if (params[key] == null) { + delete params[key] + } + }) + + // $select arrives as comma string (built-in presets) or array (saved presets), form needs [{label,value}] + if (Array.isArray(params.$select)) { + params.$select = params.$select.map((item) => + typeof item === 'string' ? { label: item, value: item } : item + ) + } else if (typeof params.$select === 'string' && params.$select !== '') { + params.$select = params.$select.split(',').map((item) => ({ label: item, value: item })) + } else { + params.$select = [] + } + + // Convert version string to autocomplete object format, default to beta if not present + if (params.version) { + const versionValue = + typeof params.version === 'string' ? params.version : params.version.value + params.version = { label: versionValue, value: versionValue } + } else { + params.version = { label: 'beta', value: 'beta' } + } + + params.id = selectedPresets.value + setSelectedPreset(selectedPresets.value) + params.name = selectedPresets.label + + // save last preset title + setLastPresetTitle(selectedPresets.label) + formControl.reset(params, { keepDefaultValues: true }) + + // Notify parent when preset changes in this component + if (onPresetSelect) { + onPresetSelect(selectedPresets) + } + } + }, [selectedPresets]) + + const schedulerForm = useForm({ + mode: 'onChange', + }) + + const schedulerCommand = { + Function: 'Get-GraphRequestList', + Synopsis: 'Execute a Graph query', + Parameters: [ + { + Name: 'Endpoint', + Type: 'System.String', + Description: 'Graph API endpoint', + Required: true, + }, + { + Name: 'Parameters', + Type: 'System.Collections.Hashtable', + Description: 'API Parameters', + Required: false, + }, + { + Name: 'queueId', + Type: 'System.String', + Description: 'Queue Id', + Required: false, + }, + { + Name: 'NoPagination', + Type: 'System.Management.Automation.SwitchParameter', + Description: 'Disable pagination', + Required: false, + }, + { + Name: 'CountOnly', + Type: 'System.Management.Automation.SwitchParameter', + Description: 'Only return count of results', + Required: false, + }, + { + Name: 'ReverseTenantLookup', + Type: 'System.Management.Automation.SwitchParameter', + Description: 'Perform reverse tenant lookup', + Required: false, + }, + { + Name: 'ReverseTenantLookupProperty', + Type: 'System.String', + Description: 'Property to perform reverse tenant lookup', + Required: false, + }, + { + Name: 'AsApp', + Type: 'System.Boolean', + Description: null, + Required: false, + }, + ], + } + // Schedule report function + const handleScheduleReport = () => { + const formParameters = watchedValues + const selectString = formParameters.$select + ? formParameters.$select?.map((item) => item.value).join(',') + : null + + //compose the parameters for the form based on what is available + var Parameters = [ + { + Key: '$select', + Value: selectString, + }, + { + Key: '$filter', + Value: formParameters.$filter, + }, + { + Key: '$top', + Value: formParameters.$top, + }, + { + Key: '$search', + Value: formParameters.$search, + }, + { + Key: '$count', + Value: formParameters.$count, + }, + { + Key: '$expand', + Value: formParameters.$expand, + }, + { + Key: '$orderby', + Value: formParameters.$orderby, + }, + { + Key: '$format', + Value: formParameters.$format, + }, + ] + Parameters = Parameters.filter((param) => { + return ( + param.Value != null && + param.Value !== '' && + !(typeof param.Value === 'boolean' && param.Value === false) + ) + }) + const resetParams = { + tenantFilter: tenant, + Name: formParameters.name + ? `Graph Explorer - ${formParameters.name}` + : 'Graph Explorer Report', + command: { + label: schedulerCommand.Function, + value: schedulerCommand.Function, + addedFields: schedulerCommand, + }, + parameters: { + Endpoint: formParameters.endpoint, + skipCache: true, + NoPagination: formParameters.NoPagination, + AsApp: formParameters.AsApp, + ReverseTenantLookup: formParameters.ReverseTenantLookup, + ReverseTenantLookupProperty: formParameters.ReverseTenantLookupProperty, + Parameters: Parameters, + }, + advancedParameters: false, + Recurrence: { + value: 0, + label: 'Only once', + }, + } + schedulerForm.reset(resetParams) + setOffCanvasContent( + <> + + Schedule Graph Explorer Report + + + + ) + setOffCanvasOpen(true) + } + + const [editorValues, setEditorValues] = useState({}) + //keep the editor in sync with the form + + function getPresetProps(values) { + var newvals = Object.assign({}, values) + if (newvals?.$select !== undefined && Array.isArray(newvals?.$select)) { + newvals.$select = newvals?.$select.map((p) => p.value).join(',') + } + if (newvals.version && newvals.version.value) { + newvals.version = newvals.version.value + } else if (!newvals.version) { + newvals.version = 'beta' + } + delete newvals['reportTemplate'] + delete newvals['tenantFilter'] + delete newvals['IsShared'] + delete newvals['id'] + if (newvals.ReverseTenantLookup === false) { + delete newvals.ReverseTenantLookup + } + if (newvals.NoPagination === false) { + delete newvals.NoPagination + } + if (newvals.$count === false) { + delete newvals.$count + } + if (newvals.AsApp === false) { + delete newvals.AsApp + } + Object.keys(newvals).forEach((key) => { + if (values[key] === '' || values[key] === null) { + delete newvals[key] + } + }) + return newvals + } + + useEffect(() => { + var values = getPresetProps(watchedValues) + setOffCanvasContent(() => ( + <> + + Import / Export Graph Explorer Preset + + + Copy the JSON below to export your preset, or paste a preset JSON to import it. + + setEditorValues(JSON.parse(value))} + code={JSON.stringify(values, null, 2)} + /> + + + + )) + }, [editorValues, savePresetApi.isPending, formControl, selectedPresets, watchedValues]) + + const handleImport = () => { + setOffCanvasOpen(true) // Open the offCanvas, the content will be updated by useEffect + } + // Handle filter form submission + const onSubmit = (values) => { + if (Array.isArray(values.$select)) { + // empty array joins to '', removed by the null/empty cleanup below + values.$select = values.$select.map((item) => item.value).join(',') + } + if (values.version && values.version.value) { + values.version = values.version.value + } else if (!values.version) { + values.version = 'beta' + } + if (values.ReverseTenantLookup === false) { + delete values.ReverseTenantLookup + } + if (values.NoPagination === false) { + delete values.NoPagination + } + if (values.$count === false) { + delete values.$count + } + if (values.AsApp === false) { + delete values.AsApp + } + + // Remove non-API fields and null/empty values + delete values.name + delete values.id + delete values.IsShared + delete values.reportTemplate + delete values.manualPagination + if (!values.ReverseTenantLookup) { + // property only means something with the lookup enabled + delete values.ReverseTenantLookupProperty + } + + Object.keys(values).forEach((key) => { + if (values[key] === null || values[key] === '') { + delete values[key] + } + }) + + if (onPresetChange) { + const presetName = lastPresetTitle ? `Graph Explorer - ${lastPresetTitle}` : null + if (presetName) onPresetChange(presetName) + } + onSubmitFilter(values) + } + + //console.log(cardExpanded); + return ( +
    + setCardExpanded(expanded)} + cardSx={{ + width: '100%', + height: '100%', + mb: 2, + }} + > + + + option.type} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} + placeholder="Select a preset" + helperText="Select an existing preset to load its parameters" + /> +
    + + {/* Preset Name Field */} + + + + + + + The{' '} + + Graph endpoint + {' '} + to query (e.g. https://graph.microsoft.com/beta/$Endpoint) + + } + /> + + + + + + + + 0 && + propertyList?.data?.Results?.map((item) => ({ label: item, value: item }))) || [ + { + label: 'No properties found, check your endpoint', + value: '', + }, + ] + } + placeholder="Columns to select" + helperText="List of object properties to include in the response" + /> + + + {/* Filter Field */} + + + Graph $filter query + + } + /> + + + {/* Expand Field */} + + + + + {/* Top Field */} + + + + + {/* Search Field */} + + + + + {/* OrderBy Field */} + + + + + {/* Format Field */} + + + +
    + + {/* Reverse Tenant Lookup Switch */} + + + + + {/* Reverse Tenant Lookup Property Field */} + + + + + {/* No Pagination Switch */} + + + + {/* $count Switch */} + + + + + {/* AsApp switch */} + + + + + + {/* Footer-style action section */} + {!hideButtons && ( + + + + {component === 'accordion' ? ( + + + + + + + + + + + + + + + ) : ( + + + + + + + + + + + + + + + + + + + + + + + )} + + + )} +
    + setOffCanvasOpen(false)} + children={offCanvasContent} + /> + + ) +} + +export default CippGraphExplorerFilter diff --git a/src/components/CippTable/CippGraphExplorerSimpleFilter.js b/src/components/CippTable/CippGraphExplorerSimpleFilter.js deleted file mode 100644 index da880c9fbf0e..000000000000 --- a/src/components/CippTable/CippGraphExplorerSimpleFilter.js +++ /dev/null @@ -1,221 +0,0 @@ -import { useState, useEffect } from "react"; -import { Button, Stack, Box } from "@mui/material"; -import { PlayCircle, ManageSearch, TableChart, Code } from "@mui/icons-material"; -import { useForm, useWatch } from "react-hook-form"; -import CippFormComponent from "../CippComponents/CippFormComponent"; -import { ApiGetCall } from "../../api/ApiCall"; -import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; -import CippGraphExplorerFilter from "./CippGraphExplorerFilter"; -import defaultPresets from "../../data/GraphExplorerPresets"; -import { GroupHeader, GroupItems } from "../CippComponents/CippAutocompleteGrouping"; - -const CippGraphExplorerSimpleFilter = ({ - onSubmitFilter, - onPresetChange, - relatedQueryKeys = [], - viewMode = "table", - onViewModeChange, -}) => { - const [offCanvasVisible, setOffCanvasVisible] = useState(false); - const [presetOptions, setPresetOptions] = useState([]); - const [currentFilterValues, setCurrentFilterValues] = useState(null); - - const presetControl = useForm({ - mode: "onChange", - defaultValues: { - reportTemplate: null, - }, - }); - - const selectedPreset = useWatch({ control: presetControl.control, name: "reportTemplate" }); - - // API call for available presets - const presetList = ApiGetCall({ - url: "/api/ListGraphExplorerPresets", - queryKey: "ListGraphExplorerPresets", - }); - - useEffect(() => { - var presetOptionList = []; - defaultPresets.forEach((item) => { - presetOptionList.push({ - label: item.name, - value: item.id, - addedFields: item, - type: "Built-In", - }); - }); - if (presetList.isSuccess && presetList.data?.Results.length > 0) { - presetList.data.Results.forEach((item) => { - presetOptionList.push({ - label: item.name, - value: item.id, - addedFields: item, - type: "Custom", - }); - }); - } - setPresetOptions(presetOptionList); - }, [defaultPresets, presetList.isSuccess, presetList.data]); - - const handleRunPreset = () => { - // re-run the last applied query from the edit drawer, preset change clears it - if (currentFilterValues) { - onSubmitFilter(currentFilterValues); - return; - } - if (selectedPreset?.addedFields?.params) { - const params = selectedPreset.addedFields.params; - const values = { ...params }; - - // Handle $select array/string conversion - if (values.$select && Array.isArray(values.$select) && values.$select.length > 0) { - values.$select = values.$select - .map((item) => (typeof item === "string" ? item : item.value)) - .join(","); - } else if (!values.$select || values.$select.length === 0) { - delete values.$select; - } - - // Handle version conversion - if (values.version && values.version.value) { - values.version = values.version.value; - } else if (!values.version) { - values.version = "beta"; - } - - // Clean up false boolean values - if (values.ReverseTenantLookup === false) { - delete values.ReverseTenantLookup; - } - if (values.NoPagination === false) { - delete values.NoPagination; - } - if (values.$count === false) { - delete values.$count; - } - if (values.AsApp === false) { - delete values.AsApp; - } - - // Remove non-API fields and null/empty values (presets saved from the form carry id/name/IsShared in params) - delete values.id; - delete values.name; - delete values.IsShared; - Object.keys(values).forEach((key) => { - if (values[key] === null || values[key] === "") { - delete values[key]; - } - }); - - // Update page title if callback provided - if (onPresetChange && selectedPreset.label) { - onPresetChange(`Graph Explorer - ${selectedPreset.label}`); - } - - setCurrentFilterValues(values); - onSubmitFilter(values); - } - }; - - const handleFilterSubmit = (values) => { - setCurrentFilterValues(values); - onSubmitFilter(values); - setOffCanvasVisible(false); - }; - - const handlePresetChange = (preset) => { - setCurrentFilterValues(null); - presetControl.setValue("reportTemplate", preset); - }; - - // preset picked directly in the bar, discard drawer edits so Run uses the pristine preset - useEffect(() => { - setCurrentFilterValues(null); - }, [selectedPreset?.value]); - - return ( - <> - {/* This sits on a page with no Card, so at 390px there is ~358px for a query field - plus three buttons whose fixed minimums alone came to 340px. */} - - - option.type} - renderGroup={(params) => ( -
  • - {params.group} - {params.children} -
  • - )} - placeholder="Select a query to run" - /> -
    - - - - {onViewModeChange && ( - - )} - -
    - - setOffCanvasVisible(false)} - contentPadding={1} - keepMounted={true} - > - - - - ); -}; - -export default CippGraphExplorerSimpleFilter; diff --git a/src/components/CippTable/CippGraphExplorerSimpleFilter.jsx b/src/components/CippTable/CippGraphExplorerSimpleFilter.jsx new file mode 100644 index 000000000000..03578f8296ea --- /dev/null +++ b/src/components/CippTable/CippGraphExplorerSimpleFilter.jsx @@ -0,0 +1,228 @@ +import { useState, useEffect } from "react"; +import { CippIcons } from "../../utils/icon-registry"; +import { Button, Stack, Box } from "@mui/material"; +import { useForm, useWatch } from "react-hook-form"; +import CippFormComponent from "../CippComponents/CippFormComponent"; +import { ApiGetCall } from "../../api/ApiCall"; +import { CippOffCanvas } from "../CippComponents/CippOffCanvas"; +import CippGraphExplorerFilter from "./CippGraphExplorerFilter"; +import defaultPresets from "../../data/GraphExplorerPresets"; +import { GroupHeader, GroupItems } from "../CippComponents/CippAutocompleteGrouping"; + +const CippGraphExplorerSimpleFilter = ({ + onSubmitFilter, + onPresetChange, + relatedQueryKeys = [], + viewMode = "table", + onViewModeChange, +}) => { + const [offCanvasVisible, setOffCanvasVisible] = useState(false); + const [presetOptions, setPresetOptions] = useState([]); + const [currentFilterValues, setCurrentFilterValues] = useState(null); + + const presetControl = useForm({ + mode: "onChange", + defaultValues: { + reportTemplate: null, + }, + }); + + const selectedPreset = useWatch({ control: presetControl.control, name: "reportTemplate" }); + + // API call for available presets + const presetList = ApiGetCall({ + url: "/api/ListGraphExplorerPresets", + queryKey: "ListGraphExplorerPresets", + }); + + useEffect(() => { + var presetOptionList = []; + defaultPresets.forEach((item) => { + presetOptionList.push({ + label: item.name, + value: item.id, + addedFields: item, + type: "Built-In", + }); + }); + if (presetList.isSuccess && presetList.data?.Results.length > 0) { + presetList.data.Results.forEach((item) => { + presetOptionList.push({ + label: item.name, + value: item.id, + addedFields: item, + type: "Custom", + }); + }); + } + setPresetOptions(presetOptionList); + }, [defaultPresets, presetList.isSuccess, presetList.data]); + + const handleRunPreset = () => { + // re-run the last applied query from the edit drawer, preset change clears it + if (currentFilterValues) { + onSubmitFilter(currentFilterValues); + return; + } + if (selectedPreset?.addedFields?.params) { + const params = selectedPreset.addedFields.params; + const values = { ...params }; + + // Handle $select array/string conversion + if (values.$select && Array.isArray(values.$select) && values.$select.length > 0) { + values.$select = values.$select + .map((item) => (typeof item === "string" ? item : item.value)) + .join(","); + } else if (!values.$select || values.$select.length === 0) { + delete values.$select; + } + + // Handle version conversion + if (values.version && values.version.value) { + values.version = values.version.value; + } else if (!values.version) { + values.version = "beta"; + } + + // Clean up false boolean values + if (values.ReverseTenantLookup === false) { + delete values.ReverseTenantLookup; + } + if (values.NoPagination === false) { + delete values.NoPagination; + } + if (values.$count === false) { + delete values.$count; + } + if (values.AsApp === false) { + delete values.AsApp; + } + + // Remove non-API fields and null/empty values (presets saved from the form carry id/name/IsShared in params) + delete values.id; + delete values.name; + delete values.IsShared; + Object.keys(values).forEach((key) => { + if (values[key] === null || values[key] === "") { + delete values[key]; + } + }); + + // Update page title if callback provided + if (onPresetChange && selectedPreset.label) { + onPresetChange(`Graph Explorer - ${selectedPreset.label}`); + } + + setCurrentFilterValues(values); + onSubmitFilter(values); + } + }; + + const handleFilterSubmit = (values) => { + setCurrentFilterValues(values); + onSubmitFilter(values); + setOffCanvasVisible(false); + }; + + const handlePresetChange = (preset) => { + setCurrentFilterValues(null); + presetControl.setValue("reportTemplate", preset); + }; + + // preset picked directly in the bar, discard drawer edits so Run uses the pristine preset + useEffect(() => { + setCurrentFilterValues(null); + }, [selectedPreset?.value]); + + return ( + <> + {/* This sits on a page with no Card, so at 390px there is ~358px for a query field + plus three buttons whose fixed minimums alone came to 340px. */} + + + option.type} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} + placeholder="Select a query to run" + /> +
    + + + + {onViewModeChange && ( + + )} + +
    + + setOffCanvasVisible(false)} + contentPadding={1} + keepMounted={true} + > + + + + ); +}; + +export default CippGraphExplorerSimpleFilter; diff --git a/src/components/CippTable/CippMobileCardList.jsx b/src/components/CippTable/CippMobileCardList.jsx index fa208ae5eb2f..4f67e56628aa 100644 --- a/src/components/CippTable/CippMobileCardList.jsx +++ b/src/components/CippTable/CippMobileCardList.jsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { CippIcons } from "../../utils/icon-registry"; import { Box, Button, @@ -15,14 +16,14 @@ import { Typography, } from "@mui/material"; import { flexRender } from "material-react-table"; -import { Info, MoreVert, MoreHoriz, SearchOff } from "@mui/icons-material"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { renderUrlValue } from "../../utils/render-url-value"; import { getMobileCardSlots } from "./util-mobile-card-slots"; import { CippBottomSheet } from "../CippComponents/CippBottomSheet"; import { CippPageActionsFab } from "../CippComponents/CippPageActionsFab"; -import { useActionCornerClaim } from "../../layouts/tab-navigation-context"; +import { useActionCornerClaim, useTabNavigation } from "../../layouts/tab-navigation-context"; import { useSheetHandoff } from "../../hooks/use-sheet-handoff"; +import { isRowTextInteraction } from "./util-row-text-interaction"; // Mobile card pageSize ceiling: a desktop tablePageSize of 250/500 must not become // 250 unvirtualized cards. "Load more" grows pageSize from here in steps of LOAD_STEP. @@ -91,6 +92,7 @@ export const CippMobileCardList = (props) => { table, actions, hasOffCanvas = false, + openOffCanvasOnTap = false, onRowAction, onMoreInfo, isActionDisabled, @@ -105,6 +107,7 @@ export const CippMobileCardList = (props) => { } = props; const [actionSheetRow, setActionSheetRow] = useState(null); + const cardTapStartRef = useRef(null); // Row actions and More info both open a Modal — hand the sheet off rather than racing it const rowSheet = useSheetHandoff(() => setActionSheetRow(null)); @@ -113,6 +116,11 @@ export const CippMobileCardList = (props) => { // behind the bulk bar. Navigation is unaffected — the tab picker is in the title row. useActionCornerClaim(fixedChrome && selectMode); + // A tabbed layout drops its own fixed FAB over the content whenever nothing claimed the + // corner, so an embedded (noCard) list sits under it too and still owes the reservation. + const tabNav = useTabNavigation(); + const hasLayoutFab = Boolean(tabNav?.enabled) && (tabNav?.actions?.length ?? 0) > 0; + // A desktop tablePageSize above the cap would render that many unvirtualized cards. useEffect(() => { if (table.getState().pagination.pageSize > MOBILE_PAGE_SIZE_CAP) { @@ -138,7 +146,8 @@ export const CippMobileCardList = (props) => { const rowActionItems = (row) => (actions ?? []).filter( (action) => - typeof action.hideCondition !== "function" || !action.hideCondition(getActionRow(row.original)) + typeof action.hideCondition !== "function" || + !action.hideCondition(getActionRow(row.original)) ); // Detail rows that would waste space: empty values, or values already shown as the @@ -169,11 +178,14 @@ export const CippMobileCardList = (props) => { ) { return; } + if (isRowTextInteraction(cardTapStartRef.current, event)) { + return; + } if (selectMode) { row.toggleSelected(); return; } - if (hasOffCanvas) { + if (openOffCanvasOnTap) { onMoreInfo?.(row.original); } }; @@ -187,25 +199,34 @@ export const CippMobileCardList = (props) => { return ( {isStreaming && !showSkeletons && } - {/* pb clears the fixed FAB / bulk bar — chrome an embedded (noCard/dialog) list does - not have, so it pays a normal gap instead of 80px of blank card. */} + {/* pb clears the fixed FAB / bulk bar — chrome an embedded (noCard/dialog) list only + has when its layout supplies the FAB; otherwise it pays a normal gap. */} {showSkeletons ? ( Array.from({ length: 5 }, (_, i) => ) ) : totalFiltered === 0 ? ( - {queueMessage ? : } + {queueMessage ? : } {queueMessage ?? "No results"} {hasActiveFilter && ( <> - + Nothing matches the current search and filters. 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} /> - - - )} - - {/* Combined Preview and Configuration Dialog */} - - - - Executive Report - {tenantName} - - - {/* The config rail's stand-in below md, in the title bar because the dialog is - full-screen there and this is the only chrome that stays put. */} - setSectionsOpen(true)} - size="small" - aria-label="Report sections" - sx={{ display: { xs: 'inline-flex', md: 'none' } }} - > - - - - - - - - - - {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} - - {sectionPanel()} - - - {/* Right Panel - PDF Preview */} - - {isDataLoading ? ( - - Loading Report Data... - - Fetching additional data for comprehensive report generation - - - ) : reportDocument ? ( - - {reportDocument} - - ) : ( - - - Report preview will appear here - - - )} - - - - :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, - }} - > - - - Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} - {sectionOptions.length} - - - - - - - - - {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over - the dialog it is opened from. */} - setSectionsOpen(false)} - title="Report Sections" - size="sm" - contentPadding={0} - aboveModal - > - {sectionPanel({ showHeading: false })} - - - - ) -} diff --git a/src/components/ExecutiveReportButton.jsx b/src/components/ExecutiveReportButton.jsx new file mode 100644 index 000000000000..e4d5b6369cee --- /dev/null +++ b/src/components/ExecutiveReportButton.jsx @@ -0,0 +1,2102 @@ +import { useState, useMemo } from 'react' +import { CippIcons } from '../utils/icon-registry' +import { + Button, + Tooltip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Box, + Typography, + Switch, + Paper, + Stack, + IconButton, + MenuItem, + ListItemIcon, + ListItemText, + TextField, +} from '@mui/material' +import { CippAutoComplete } from './CippComponents/CippAutocomplete' +import { CippOffCanvas } from './CippComponents/CippOffCanvas' +import { Document, Page, Text, View, Image } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' +import { useSettings } from '../hooks/use-settings' +import { useSecureScore } from '../hooks/use-securescore' +import { ApiGetCall } from '../api/ApiCall' +import { ShadowAIReportPages } from './ShadowAIReportButton' +import { DEFAULT_BRANDING_OPTION } from './ReportBuilder/reportSettings' +import { useReportVariables } from './CippPdf/useReportVariables' +import { useBrandingSettings } from './CippPdf/useBrandingSettings' +import { isCloudPcDevice } from '../utils/is-cloud-pc-device' +import { + Bold, + BulletList, + ContentPage, + DataTable, + DonutChart, + HeroPage, + InfoBox, + Paragraph, + ReportDocument, + REPORT_SERIES_SEMANTIC, + Section, + StatRow, + StatusText, + TrendChart, +} from './CippPdf' + +// Conditional access policy states, as Graph spells them and as a reader should read them. +const CA_STATE_LABELS = { + enabled: 'Enabled', + enabledForReportingButNotEnforced: 'Report Only', + disabled: 'Disabled', +} +const CA_STATE_TONES = { + enabled: 'pass', + enabledForReportingButNotEnforced: 'warn', + disabled: 'fail', +} +const CA_CONTROL_LABELS = { mfa: 'MFA', block: 'Block', compliantDevice: 'Compliant Device' } + +const caControlsText = (policy) => { + const controls = Object.entries(CA_CONTROL_LABELS) + .filter(([control]) => policy.builtInControls?.includes(control)) + .map(([, label]) => label) + return controls.length > 0 ? controls.join(', ') : 'Custom' +} + +// The generated standards catalogue, loaded once. Three separate `require` calls inside render +// paths used to reload it per deviation row. +let standardsCatalog = null +try { + standardsCatalog = require('../data/standards.json') +} catch (error) { + standardsCatalog = null +} + +// PRODUCTION-GRADE PDF SYSTEM WITH CONDITIONAL RENDERING +// Exported so the report can be rendered to a real PDF in tests. A report that only throws once +// the renderer walks it is the failure mode a stubbed test cannot see. +export const ExecutiveReportDocument = ({ + tenantName, + userStats, + brandingSettings, + variables, + secureScoreData, + licensingData, + deviceData, + conditionalAccessData, + standardsCompareData, + driftComplianceData, + standardTemplatesData, + shadowAIData, + sectionConfig = { + executiveSummary: true, + securityStandards: true, + driftCompliance: false, + secureScore: true, + licenseManagement: true, + deviceManagement: true, + conditionalAccess: true, + infographics: true, + shadowAI: false, + }, +}) => { + const currentDate = new Date().toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + + // Both arrive as an array on the happy path and as an error object or `undefined` when their + // endpoint failed. The page-level guards below check that, but these run unconditionally, so + // they have to check it too. + const caPolicies = Array.isArray(conditionalAccessData) ? conditionalAccessData : [] + const devices = Array.isArray(deviceData) ? deviceData : [] + + const caEnabledCount = caPolicies.filter((policy) => policy.state === 'enabled').length + const caReportOnlyCount = caPolicies.filter( + (policy) => policy.state === 'enabledForReportingButNotEnforced' + ).length + + // Secure score history, oldest first, in the shape the shared trend chart takes. The API returns + // newest first, which would draw the trend backwards. + const scoreTrendData = [...(secureScoreData?.secureScore?.data?.Results ?? [])] + .reverse() + .map((point) => ({ + label: new Date(point.createdDateTime).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }), + value: point.currentScore, + })) + + // PROCESS REAL STANDARDS DATA + const processStandardsData = (apiData, standardTemplates) => { + const standardsData = standardsCatalog + + if (!apiData || !Array.isArray(apiData) || apiData.length === 0) { + return [] + } + + // Build a lookup map from template configurations + // Format: { "GUID": "Display Name" } + const templateDisplayNameMap = {} + + if (standardTemplates && Array.isArray(standardTemplates)) { + standardTemplates.forEach((template) => { + if (template.standards) { + // Process IntuneTemplate arrays + if (Array.isArray(template.standards.IntuneTemplate)) { + template.standards.IntuneTemplate.forEach((templateItem) => { + if (templateItem?.TemplateList?.value && templateItem?.TemplateList?.label) { + templateDisplayNameMap[templateItem.TemplateList.value.toLowerCase()] = + templateItem.TemplateList.label + } + // Handle TemplateList-Tags expansion + const tagTemplates = + templateItem?.['TemplateList-Tags']?.addedFields?.templates || + templateItem?.['TemplateList-Tags']?.rawData?.templates + if (tagTemplates && Array.isArray(tagTemplates)) { + tagTemplates.forEach((expandedTemplate) => { + if ( + expandedTemplate?.GUID && + (expandedTemplate?.displayName || expandedTemplate?.name) + ) { + templateDisplayNameMap[expandedTemplate.GUID.toLowerCase()] = + expandedTemplate.displayName || expandedTemplate.name + } + }) + } + }) + } + // Process ConditionalAccessTemplate arrays + if (Array.isArray(template.standards.ConditionalAccessTemplate)) { + template.standards.ConditionalAccessTemplate.forEach((templateItem) => { + if (templateItem?.TemplateList?.value && templateItem?.TemplateList?.label) { + templateDisplayNameMap[templateItem.TemplateList.value.toLowerCase()] = + templateItem.TemplateList.label + } + // Handle TemplateList-Tags expansion + const tagTemplates = + templateItem?.['TemplateList-Tags']?.addedFields?.templates || + templateItem?.['TemplateList-Tags']?.rawData?.templates + if (tagTemplates && Array.isArray(tagTemplates)) { + tagTemplates.forEach((expandedTemplate) => { + if ( + expandedTemplate?.GUID && + (expandedTemplate?.displayName || expandedTemplate?.name) + ) { + templateDisplayNameMap[expandedTemplate.GUID.toLowerCase()] = + expandedTemplate.displayName || expandedTemplate.name + } + }) + } + }) + } + } + }) + } + + const processedStandards = [] + const tenantData = apiData[0] // Get the first tenant's data + + // Process each standard from the API response + Object.keys(tenantData).forEach((key) => { + if (key.startsWith('standards.') && key !== 'tenantFilter') { + const standardKey = key + const standardValue = tenantData[key] + const standardDef = standardsData?.find((std) => std.name === standardKey) + + if (standardDef) { + // Determine compliance status using the same logic as applied-standards.js + let status = 'Review' + let isCompliant = false + + // FIRST: Check if CurrentValue and ExpectedValue exist and match + if ( + standardValue?.CurrentValue !== undefined && + standardValue?.ExpectedValue !== undefined + ) { + const sortedCurrent = + typeof standardValue.CurrentValue === 'object' && standardValue.CurrentValue !== null + ? Object.keys(standardValue.CurrentValue) + .sort() + .reduce((obj, key) => { + obj[key] = standardValue.CurrentValue[key] + return obj + }, {}) + : standardValue.CurrentValue + const sortedExpected = + typeof standardValue.ExpectedValue === 'object' && + standardValue.ExpectedValue !== null + ? Object.keys(standardValue.ExpectedValue) + .sort() + .reduce((obj, key) => { + obj[key] = standardValue.ExpectedValue[key] + return obj + }, {}) + : standardValue.ExpectedValue + isCompliant = JSON.stringify(sortedCurrent) === JSON.stringify(sortedExpected) + } + // SECOND: Check if Value is explicitly true + else if (standardValue?.Value === true) { + isCompliant = true + } + + status = isCompliant ? 'Compliant' : 'Review' + + // Get tags for display - fix the tags access + const tags = + standardDef.tag && Array.isArray(standardDef.tag) && standardDef.tag.length > 0 + ? standardDef.tag.slice(0, 2).join(', ') // Show first 2 tags + : 'No tags' + processedStandards.push({ + name: standardDef.label, + description: + standardDef.executiveText || standardDef.helpText || 'No description available', + status: status, + tags: tags, + }) + } else { + // If no definition found, still add it with basic info + let status = 'Review' + let isCompliant = false + + // FIRST: Check if CurrentValue and ExpectedValue exist and match + if ( + standardValue?.CurrentValue !== undefined && + standardValue?.ExpectedValue !== undefined + ) { + const sortedCurrent = + typeof standardValue.CurrentValue === 'object' && standardValue.CurrentValue !== null + ? Object.keys(standardValue.CurrentValue) + .sort() + .reduce((obj, key) => { + obj[key] = standardValue.CurrentValue[key] + return obj + }, {}) + : standardValue.CurrentValue + const sortedExpected = + typeof standardValue.ExpectedValue === 'object' && + standardValue.ExpectedValue !== null + ? Object.keys(standardValue.ExpectedValue) + .sort() + .reduce((obj, key) => { + obj[key] = standardValue.ExpectedValue[key] + return obj + }, {}) + : standardValue.ExpectedValue + isCompliant = JSON.stringify(sortedCurrent) === JSON.stringify(sortedExpected) + } + // SECOND: Check if Value is explicitly true + else if (standardValue?.Value === true) { + isCompliant = true + } + + status = isCompliant ? 'Compliant' : 'Review' + + // Create a proper name from the key - handle template types specially + let displayName = '' + + // Check if this is an IntuneTemplate or ConditionalAccessTemplate + const intuneTemplateMatch = standardKey.match(/^standards\.IntuneTemplate\.([0-9a-f-]+)/i) + const caTemplateMatch = standardKey.match( + /^standards\.ConditionalAccessTemplate\.([0-9a-f-]+)/i + ) + + if (intuneTemplateMatch) { + // IntuneTemplate - look up display name from template configurations + const guid = intuneTemplateMatch[1] + const lookupName = templateDisplayNameMap[guid.toLowerCase()] + displayName = lookupName || `Intune Template - ${guid.substring(0, 8)}` + } else if (caTemplateMatch) { + // ConditionalAccessTemplate - look up display name from template configurations + const guid = caTemplateMatch[1] + const lookupName = templateDisplayNameMap[guid.toLowerCase()] + displayName = lookupName || `CA Template - ${guid.substring(0, 8)}` + } else { + // Regular standard - use basic name formatting + displayName = standardKey + .replace('standards.', '') + .replace(/([A-Z])/g, ' $1') // Add space before capital letters + .replace(/^./, (str) => str.toUpperCase()) // Capitalize first letter + .trim() + } + + processedStandards.push({ + name: displayName, + description: 'Security standard implementation', + status: status, + tags: 'No tags', + }) + } + } + }) + + return processedStandards + } + + // PROCESS DRIFT COMPLIANCE DATA + const processDriftComplianceData = (driftData, standardsCompareData) => { + if (!driftData || !Array.isArray(driftData) || driftData.length === 0) { + return { + acceptedDeviationsCount: 0, + currentDeviationsCount: 0, + deniedDeviationsCount: 0, + customerSpecificDeviationsCount: 0, + alignedCount: 0, + acceptedDeviations: [], + currentDeviations: [], + deniedDeviations: [], + customerSpecificDeviations: [], + appliedStandards: [], + } + } + + const standardsData = standardsCatalog + + // Helper function to get pretty name from standards.json (same as manage-drift) + const getStandardPrettyName = (standardName) => { + if (!standardName) return 'Unknown Standard' + const standard = standardsData?.find((s) => s.name === standardName) + if (standard && standard.label) { + return standard.label + } + return null + } + + // Helper function to process deviations with pretty names + const processDeviations = (deviations) => { + return (deviations || []).map((deviation) => ({ + ...deviation, + prettyName: + deviation.standardDisplayName || + getStandardPrettyName(deviation.standardName) || + deviation.standardName || + 'Unknown Standard', + })) + } + + // Aggregate data across all standards for this tenant + const aggregatedData = driftData.reduce( + (acc, item) => { + acc.acceptedDeviationsCount += item.acceptedDeviationsCount || 0 + acc.currentDeviationsCount += item.currentDeviationsCount || 0 + acc.alignedCount += item.alignedCount || 0 + acc.customerSpecificDeviationsCount += item.customerSpecificDeviationsCount || 0 + acc.deniedDeviationsCount += item.deniedDeviationsCount || 0 + + // Collect deviations with pretty names + if (item.currentDeviations && Array.isArray(item.currentDeviations)) { + acc.currentDeviations.push( + ...processDeviations(item.currentDeviations.filter((dev) => dev !== null)) + ) + } + if (item.acceptedDeviations && Array.isArray(item.acceptedDeviations)) { + acc.acceptedDeviations.push( + ...processDeviations(item.acceptedDeviations.filter((dev) => dev !== null)) + ) + } + if (item.customerSpecificDeviations && Array.isArray(item.customerSpecificDeviations)) { + acc.customerSpecificDeviations.push( + ...processDeviations(item.customerSpecificDeviations.filter((dev) => dev !== null)) + ) + } + if (item.deniedDeviations && Array.isArray(item.deniedDeviations)) { + acc.deniedDeviations.push( + ...processDeviations(item.deniedDeviations.filter((dev) => dev !== null)) + ) + } + + return acc + }, + { + acceptedDeviationsCount: 0, + currentDeviationsCount: 0, + alignedCount: 0, + customerSpecificDeviationsCount: 0, + deniedDeviationsCount: 0, + currentDeviations: [], + acceptedDeviations: [], + customerSpecificDeviations: [], + deniedDeviations: [], + appliedStandards: [], + } + ) + + // Get complete list of applied standards from standards comparison data (like policies-deployed) + if ( + standardsData && + standardsCompareData && + Array.isArray(standardsCompareData) && + standardsCompareData.length > 0 + ) { + const tenantData = standardsCompareData[0] + const appliedStandards = [] + + // Process each standard from the API response + Object.keys(tenantData).forEach((key) => { + if (key.startsWith('standards.') && key !== 'tenantFilter') { + const standardKey = key + const standardDef = standardsData.find((std) => std.name === standardKey) + + if (standardDef) { + appliedStandards.push({ + name: standardDef.label || standardKey, + executiveDescription: + standardDef.executiveText || standardDef.helpText || 'No description available', + category: standardDef.cat || 'General', + }) + } + } + }) + + aggregatedData.appliedStandards = appliedStandards + } + + return aggregatedData + } + + let securityControls = processStandardsData(standardsCompareData, standardTemplatesData) + let driftComplianceInfo = processDriftComplianceData(driftComplianceData, standardsCompareData) + + // Compliance grade -> the shared status vocabulary. + const badgeTone = (status) => { + switch (status) { + case 'Compliant': + return 'pass' + case 'Partial': + return 'warn' + case 'Review': + case 'Review Required': + return 'fail' + default: + return null + } + } + + // The four deviation buckets flattened into one set of table rows. They were four copies of the + // same twenty lines, each re-requiring standards.json inside its own map. + const deviationRows = [ + { + list: driftComplianceInfo?.currentDeviations, + take: 5, + status: 'Current', + tone: 'fail', + fallback: 'Policy deviation detected', + }, + { + list: driftComplianceInfo?.acceptedDeviations, + take: 3, + status: 'Accepted', + tone: 'pass', + fallback: 'Accepted policy deviation', + }, + { + list: driftComplianceInfo?.customerSpecificDeviations, + take: 3, + status: 'Client Specific', + tone: 'warn', + fallback: 'Customer-specific policy configuration', + }, + { + list: driftComplianceInfo?.deniedDeviations, + take: 2, + status: 'Denied', + tone: 'fail', + fallback: 'Denied policy deviation', + }, + ].flatMap((bucket) => + (bucket.list ?? []).slice(0, bucket.take).map((deviation) => { + const standardDef = standardsCatalog?.find((std) => std.name === deviation.standardName) + return { + policy: deviation.prettyName || 'Unknown Policy', + description: standardDef?.executiveText || standardDef?.helpText || bucket.fallback, + status: bucket.status, + tone: bucket.tone, + } + }) + ) + + // Compliance state arrives under either casing depending on which endpoint fed the report. + const isCompliant = (device) => + (device.complianceState || device.ComplianceState || '').toLowerCase() === 'compliant' + const compliantDevices = devices.filter(isCompliant) + + // Applied standards grouped under their category heading. + const appliedStandardsByCategory = Object.entries( + (driftComplianceInfo?.appliedStandards ?? []).reduce((groups, standard) => { + const category = standard.category || 'General' + ;(groups[category] ??= []).push(standard) + return groups + }, {}) + ) + + return ( + + {/* EXECUTIVE SUMMARY - MODULAR COMPOSITION (FROST) */} + {sectionConfig.executiveSummary && ( + +
    + + This security assessment for {tenantName || 'your organization'} provides + a clear picture of your organization's cybersecurity posture and readiness against + modern threats. We've evaluated your current security measures against industry best + practices to identify strengths and opportunities for improvement. + + + + Our assessment follows globally recognized security standards to ensure your + organization meets regulatory requirements and industry benchmarks. This approach + helps protect your business assets, maintain customer trust, and reduce operational + risks from cyber threats. + +
    + +
    + +
    +
    + )} + + {/* STATISTIC PAGE 1 - CHAPTER SPLITTER */} + {sectionConfig.infographics && ( + + of organizations experienced{'\n'} + more than one cyberattack + {'\n'} + in the past year + + } + footerText={ + <> + Proactive security prevents{'\n'} + repeated attacks + + } + /> + )} + + {/* SECURITY CONTROLS - Only show if standards data is available and enabled and drift compliance is disabled */} + {sectionConfig.securityStandards && + !sectionConfig.driftCompliance && + (() => { + return securityControls && securityControls.length > 0 + })() && ( + +
    + + Your security standards have been carefully evaluated against industry best + practices to protect your business from cyber threats while ensuring smooth daily + operations. These standards help maintain business continuity, protect sensitive + data, and meet regulatory requirements that are essential for your industry. + +
    + +
    + ( + {control.status} + ), + }, + ]} + rows={securityControls.map((control) => ({ + ...control, + name: + control.name.length > 100 + ? control.name.substring(0, 100) + '...' + : control.name, + tags: control.tags.length > 0 ? control.tags : 'No tags', + }))} + /> +
    + +
    + +
    +
    + )} + + {/* DRIFT COMPLIANCE - Only show if drift compliance is enabled and security standards is disabled */} + {sectionConfig.driftCompliance && + !sectionConfig.securityStandards && + driftComplianceInfo && + (driftComplianceInfo.currentDeviationsCount > 0 || + driftComplianceInfo.acceptedDeviationsCount > 0 || + driftComplianceInfo.deniedDeviationsCount > 0 || + driftComplianceInfo.customerSpecificDeviationsCount > 0 || + driftComplianceInfo.appliedStandards.length > 0) && ( + <> + +
    + + Your drift compliance assessment shows how your current security policies compare + to your organization's approved standards. This analysis helps identify where + configurations have drifted from intended baselines and provides insights into + policy compliance across your Microsoft 365 environment. + +
    + +
    + ({ + ...entry, + // Compliance categories keep the semantic green-to-red scale rather than the + // brand series: red has to mean denied whatever colours the MSP picked. + colour: REPORT_SERIES_SEMANTIC[index], + }))} + /> +
    + +
    + +
    + +
    + +
    +
    + + {/* Deviations Detail Page */} + {(driftComplianceInfo.currentDeviations.length > 0 || + driftComplianceInfo.acceptedDeviations.length > 0 || + driftComplianceInfo.deniedDeviations.length > 0 || + driftComplianceInfo.customerSpecificDeviations.length > 0) && ( + +
    + + The following table shows all identified policy deviations, their current + status, and executive descriptions of what each deviation means for your + organization's security posture and compliance requirements. + +
    + +
    + ( + {row.status} + ), + }, + ]} + rows={deviationRows} + /> +
    +
    + )} + + {/* Applied Standards Page */} + {driftComplianceInfo.appliedStandards.length > 0 && ( + +
    + + These are the security standards that have been applied to your Microsoft 365 + environment. Each standard represents a specific security control or policy + designed to protect your organization's data and systems. + +
    + + {appliedStandardsByCategory.map(([category, standards]) => ( +
    + ({ + label: `${standard.name}:`, + text: standard.executiveDescription, + }))} + /> +
    + ))} + +
    + + Your organization has {driftComplianceInfo.appliedStandards.length} security + standards implemented with {driftComplianceInfo.alignedCount} policies fully + aligned,{' '} + {driftComplianceInfo.acceptedDeviationsCount + + driftComplianceInfo.customerSpecificDeviationsCount}{' '} + approved deviations, and {driftComplianceInfo.currentDeviationsCount} deviations + requiring attention. + +
    +
    + )} + + )} + + {/* STATISTIC PAGE 2 - CHAPTER SPLITTER - Only show if secure score data is available and enabled */} + {sectionConfig.infographics && + sectionConfig.secureScore && + secureScoreData && + secureScoreData?.isSuccess && + secureScoreData?.translatedData && ( + + of successful cyber attacks{'\n'} + could have been prevented with{'\n'} + proactive security measures + + } + footerText={ + <> + Your security resilience is{'\n'} + our primary mission + + } + /> + )} + + {/* MICROSOFT SECURE SCORE - DEDICATED PAGE - Only show if secure score data is available and enabled */} + {sectionConfig.secureScore && + secureScoreData && + secureScoreData?.isSuccess && + secureScoreData?.translatedData && ( + +
    + + Microsoft Secure Score measures how well your organization is protected against + cyber threats. This score reflects the effectiveness of your current security + measures and helps identify areas where additional protection could strengthen your + business resilience. + +
    + +
    + +
    + +
    + +
    + + + Your current score of {secureScoreData?.translatedData?.currentScore || 'N/A'}{' '} + represents {secureScoreData?.translatedData?.percentageCurrent || 'N/A'}% of the + maximum protection level available. This indicates how well your organization is + currently defended against common cyber threats and data breaches. + + + + • Business growth and new employees may temporarily lower scores until security + measures are applied{'\n'}• Changes in software licenses can affect available security + features{'\n'}• New security threats require updated protections, which may impact + scores{'\n'}• Regular security improvements help maintain and increase your protection + level + +
    + )} + + {/* LICENSING PAGE - Only show if license data is available */} + {sectionConfig.licenseManagement && + licensingData && + Array.isArray(licensingData) && + licensingData.length > 0 && ( + <> + {/* STATISTIC PAGE 3 - CHAPTER SPLITTER */} + {sectionConfig.infographics && ( + + a business falls victim to{'\n'} + ransomware attacks + + } + footerText={ + <> + Proactive defense beats{'\n'} + reactive recovery + + } + /> + )} + +
    + + Smart license management helps control costs while ensuring your team has the + tools they need to be productive. This analysis shows how your current licenses + are being used and identifies opportunities to optimize spending without + compromising business operations. + +
    + +
    + ({ + name: license.License || license.license || 'N/A', + used: license.CountUsed || license.countUsed || '0', + available: license.CountAvailable || license.countAvailable || '0', + total: license.TotalLicenses || license.totalLicenses || '0', + }))} + /> +
    + +
    + +
    +
    + + )} + + {/* DEVICES PAGE - Only show if device data is available */} + {sectionConfig.deviceManagement && + deviceData && + Array.isArray(deviceData) && + deviceData.length > 0 && ( + <> + {/* STATISTIC PAGE 4 - CHAPTER SPLITTER */} + {sectionConfig.infographics && ( + + average cost of a{'\n'} + data breach in 2024 + + } + footerText={ + <> + Investment in security + {'\n'} + saves millions in recovery + + } + /> + )} + +
    + + Managing employee devices is essential for protecting your business data and + maintaining productivity. This analysis shows which devices meet your security + standards and identifies any that may need attention to prevent data breaches or + operational disruptions. + +
    + +
    + +
    + +
    + ( + + {row.compliance} + + ), + }, + { header: 'Last Sync', key: 'lastSync', width: 2, bold: true }, + ]} + rows={deviceData.map((device) => ({ + name: device.deviceName || 'N/A', + os: device.operatingSystem || 'N/A', + compliance: device.complianceState || device.ComplianceState || 'Unknown', + compliant: isCompliant(device), + lastSync: device.lastSyncDateTime + ? new Date(device.lastSyncDateTime).toLocaleDateString() + : 'N/A', + }))} + /> +
    + +
    + device.operatingSystem === 'Windows') + .length, + label: 'Windows Devices', + }, + { + value: deviceData.filter((device) => device.operatingSystem === 'iOS').length, + label: 'iOS Devices', + }, + { + value: deviceData.filter((device) => device.operatingSystem === 'Android') + .length, + label: 'Android Devices', + }, + { + // Cloud PCs never report BitLocker but are platform-encrypted by Azure. + value: deviceData.filter( + (device) => device.isEncrypted === true || isCloudPcDevice(device), + ).length, + label: 'Encrypted', + }, + ]} + /> +
    + + + Keep devices updated and secure to protect business data. Regularly check that all + employee devices meet security standards and address any issues promptly. Consider + automated policies to maintain consistent security across all devices and conduct + regular reviews to identify potential risks. + +
    + + )} + + {/* CONDITIONAL ACCESS POLICIES PAGE - Only show if data is available */} + {sectionConfig.conditionalAccess && + conditionalAccessData && + Array.isArray(conditionalAccessData) && + conditionalAccessData.length > 0 && ( + <> + {/* STATISTIC PAGE 5 - CHAPTER SPLITTER */} + {sectionConfig.infographics && ( + + average time to identify and{'\n'} + contain a data breach + + } + footerText={ + <> + Early detection minimizes{'\n'} + business impact + + } + /> + )} + +
    + + Access control policies help protect your business by ensuring only the right + people can access sensitive information under appropriate circumstances. These + smart security measures automatically evaluate each access request and apply + additional verification when needed, balancing security with employee + productivity. + +
    + +
    + + These policies work like intelligent security guards, making decisions based on + who is trying to access what, from where, and when. For example, accessing email + from the office might be seamless, but accessing it from an unusual location might + require additional verification. This approach protects your data while minimizing + disruption to daily work. + +
    + +
    + ( + {row.state} + ), + }, + { header: 'Applications', key: 'applications', width: 2, bold: true }, + { header: 'Controls', key: 'controls', width: 3, bold: true }, + ]} + rows={conditionalAccessData.map((policy) => ({ + name: policy.displayName || 'N/A', + state: CA_STATE_LABELS[policy.state] ?? policy.state ?? 'Unknown', + tone: CA_STATE_TONES[policy.state] ?? null, + applications: policy.includeApplications || 'All', + controls: caControlsText(policy), + }))} + /> +
    + +
    + + policy.builtInControls?.includes('mfa') + ).length, + label: 'MFA Policies', + }, + ]} + /> +
    + +
    + +
    + + + {caReportOnlyCount > 0 + ? `Consider activating ${caReportOnlyCount} policies currently in testing mode after ensuring they don't disrupt business operations. ` + : 'Your access controls are properly configured. '} + Regularly review how these policies affect employee productivity and adjust as + needed. Consider additional location-based protections for enhanced security without + impacting daily operations. + +
    + + )} + + {/* SHADOW AI SECTION - the Shadow AI report pages appended to this document. It takes the + theme from this document's context, so its pages carry the same branding as the rest. */} + {sectionConfig.shadowAI && shadowAIData && ( + + )} +
    + ) +} + +export const ExecutiveReportButton = (props) => { + const { variant: buttonVariant, onClick: onClickProp, ...other } = props + const settings = useSettings() + const defaultBranding = useBrandingSettings() + + // Preview state + const [previewOpen, setPreviewOpen] = useState(false) + // Null until the operator picks one, so the branding setting for this report type keeps applying + // as it changes. An explicit choice — including "Default" — wins from then on. + const [presetOverride, setPresetOverride] = useState(null) + const brandingPresetId = presetOverride ?? defaultBranding?.reportDefaults?.executive ?? '' + + // Named branding sets a report can be rendered against instead of the default branding. + const brandingPresets = ApiGetCall({ + url: '/api/ListBrandingPresets', + data: { includeImages: true }, + queryKey: 'ListBrandingPresets-withImages', + waiting: previewOpen, + }) + + const presetOptions = useMemo( + () => [ + // Imported rather than restated: this option used to read "Global branding settings" here + // while the branding page called the same thing "Default". + DEFAULT_BRANDING_OPTION, + ...(Array.isArray(brandingPresets.data) ? brandingPresets.data : []).map((preset) => ({ + label: preset.name, + value: preset.id, + })), + ], + [brandingPresets.data] + ) + + const brandingSettings = useMemo(() => { + if (!brandingPresetId) return defaultBranding + const presets = Array.isArray(brandingPresets.data) ? brandingPresets.data : [] + // A preset deleted since it was picked falls back to the default branding rather than + // rendering unbranded. + return presets.find((preset) => preset.id === brandingPresetId) || defaultBranding + }, [brandingPresetId, brandingPresets.data, defaultBranding]) + + const variables = useReportVariables() + + const [sectionConfig, setSectionConfig] = useState({ + executiveSummary: true, + securityStandards: true, + driftCompliance: false, + secureScore: true, + licenseManagement: true, + deviceManagement: true, + conditionalAccess: true, + infographics: true, + shadowAI: false, + }) + + // Fetch organization data - only when preview is open + const organization = ApiGetCall({ + url: '/api/ListGraphRequest', + queryKey: `${settings.currentTenant}-ListGraphRequest-organization-report`, + data: { tenantFilter: settings.currentTenant, Endpoint: 'organization' }, + waiting: previewOpen, + }) + + const organizationRecord = organization.data?.Results?.[0] + + // Fetch user counts - only when preview is open + const dashboard = ApiGetCall({ + url: '/api/ListuserCounts', + data: { tenantFilter: settings.currentTenant }, + queryKey: `${settings.currentTenant}-ListuserCounts-report`, + waiting: previewOpen, + }) + + // Only fetch additional data when preview dialog is opened + const secureScore = useSecureScore({ waiting: previewOpen }) + + // Get real license data - only when preview is open + const licenseData = ApiGetCall({ + url: '/api/ListLicenses', + data: { + tenantFilter: settings.currentTenant, + }, + queryKey: `licenses-report-${settings.currentTenant}`, + waiting: previewOpen, + }) + + // Get real device data - only when preview is open + const deviceData = ApiGetCall({ + url: '/api/ListGraphRequest', + data: { + tenantFilter: settings.currentTenant, + Endpoint: 'deviceManagement/managedDevices', + }, + queryKey: `ListGraphRequest-devices-report-${settings.currentTenant}`, + waiting: previewOpen, + }) + + // Get real conditional access policy data - only when preview is open + const conditionalAccessData = ApiGetCall({ + url: '/api/ListConditionalAccessPolicies', + data: { + tenantFilter: settings.currentTenant, + }, + queryKey: `ca-policies-report-${settings.currentTenant}`, + waiting: previewOpen, + }) + + // Get real standards data - only when preview is open + const standardsCompareData = ApiGetCall({ + url: '/api/ListStandardsCompare', + data: { + tenantFilter: settings.currentTenant, + }, + queryKey: `standards-compare-report-${settings.currentTenant}`, + waiting: previewOpen, + }) + + // Get drift compliance data - only when preview is open + const driftComplianceData = ApiGetCall({ + url: '/api/listTenantDrift', + data: { + TenantFilter: settings.currentTenant, + }, + queryKey: `drift-compliance-report-${settings.currentTenant}`, + waiting: previewOpen, + }) + + // Load all standard templates to resolve template display names + const standardTemplatesData = ApiGetCall({ + url: `/api/listStandardTemplates`, + data: {}, // No templateId filter - get all templates + queryKey: `standard-templates-report-all`, + waiting: previewOpen, + }) + + // Shadow AI data for the optional Shadow AI section - only fetched when that section is + // enabled. Requires a single tenant; the CIPPDb cache must have been synced for data to show. + const shadowAIEnabled = sectionConfig.shadowAI && settings.currentTenant !== 'AllTenants' + const shadowAIData = ApiGetCall({ + url: '/api/ListShadowAI', + data: { tenantFilter: settings.currentTenant }, + queryKey: `ListShadowAI-${settings.currentTenant}`, + waiting: previewOpen && shadowAIEnabled, + }) + + // Check if all data is loaded (either successful or failed) - only relevant when preview is open + const isDataLoading = + previewOpen && + (organization.isFetching || + dashboard.isFetching || + secureScore.isFetching || + licenseData.isFetching || + deviceData.isFetching || + conditionalAccessData.isFetching || + standardsCompareData.isFetching || + driftComplianceData.isFetching || + standardTemplatesData.isFetching || + (shadowAIEnabled && shadowAIData.isFetching)) + + const hasAllDataFinished = + !previewOpen || + ((organization.isSuccess || organization.isError) && + (dashboard.isSuccess || dashboard.isError) && + (secureScore.isSuccess || secureScore.isError) && + (licenseData.isSuccess || licenseData.isError) && + (deviceData.isSuccess || deviceData.isError) && + (conditionalAccessData.isSuccess || conditionalAccessData.isError) && + (standardsCompareData.isSuccess || standardsCompareData.isError) && + (driftComplianceData.isSuccess || driftComplianceData.isError) && + (standardTemplatesData.isSuccess || standardTemplatesData.isError) && + (!shadowAIEnabled || shadowAIData.isSuccess || shadowAIData.isError)) + + // Button is always available now since we don't need to wait for data + const shouldShowButton = true + + const tenantName = organizationRecord?.displayName || 'Tenant' + const tenantId = organizationRecord?.id + const userStats = { + licensedUsers: dashboard.data?.LicUsers || 0, + unlicensedUsers: + dashboard.data?.Users && dashboard.data?.LicUsers + ? dashboard.data?.Users - dashboard.data?.LicUsers + : 0, + guests: dashboard.data?.Guests || 0, + globalAdmins: dashboard.data?.Gas || 0, + permanentGlobalAdmins: dashboard.data?.PermanentGas ?? 0, + eligibleGlobalAdmins: dashboard.data?.EligibleGas ?? 0, + pimCapable: dashboard.data?.PIMCapable === true, + } + + const fileName = `Executive_Report_${tenantName?.replace(/[^a-zA-Z0-9]/g, '_') || 'Tenant'}_${ + new Date().toISOString().split('T')[0] + }.pdf` + + // Memoize the document to prevent unnecessary re-renders - only when dialog is open + const reportDocument = useMemo(() => { + // Don't create document if dialog is closed + if (!previewOpen) { + return null + } + + // Only create document if preview is open and data is ready + if (!hasAllDataFinished) { + return ( + + + + Loading report data... + + + + ) + } + + try { + return ( + + ) + } catch (error) { + console.error('Error creating ExecutiveReportDocument:', error) + return ( + + + + Error creating document: {error.message} + + + + ) + } + }, [ + previewOpen, // Most important - prevents creation when dialog is closed + hasAllDataFinished, + tenantName, + tenantId, + userStats, + organizationRecord, + dashboard.data, + brandingSettings, + // Resolved asynchronously, so without this the document keeps the copy built before the + // values landed and the footer shows %cippurl% instead of the URL. + variables, + secureScore?.isSuccess, + licenseData?.isSuccess, + deviceData?.isSuccess, + conditionalAccessData?.isSuccess, + standardsCompareData?.isSuccess, + driftComplianceData?.isSuccess, + shadowAIData?.isSuccess, + JSON.stringify(sectionConfig), // Stringify to prevent reference issues + ]) + + // Handle section toggle with mutual exclusion logic + const handleSectionToggle = (sectionKey) => { + setSectionConfig((prev) => { + // Count currently enabled sections + const enabledSections = Object.values(prev).filter(Boolean).length + + // If trying to disable the last remaining section, prevent it + if (prev[sectionKey] && enabledSections === 1) { + return prev // Don't change state + } + + // Mutual exclusion logic for Security Standards and Drift Compliance + if (sectionKey === 'securityStandards' && !prev[sectionKey]) { + // Enabling Security Standards, disable Drift Compliance + return { + ...prev, + securityStandards: true, + driftCompliance: false, + } + } + + if (sectionKey === 'driftCompliance' && !prev[sectionKey]) { + // Enabling Drift Compliance, disable Security Standards + return { + ...prev, + driftCompliance: true, + securityStandards: false, + } + } + + return { + ...prev, + [sectionKey]: !prev[sectionKey], + } + }) + } + + // Close handler with cleanup + const handleClose = () => { + setPreviewOpen(false) + } + + // Below md the 320px config rail would leave the preview about 70px wide, so it moves into + // a drawer and the preview takes the whole dialog. + const [sectionsOpen, setSectionsOpen] = useState(false) + + // Section configuration options + const sectionOptions = [ + { + key: 'executiveSummary', + label: 'Executive Summary', + description: 'High-level overview and statistics', + }, + { + key: 'securityStandards', + label: 'Security Standards', + description: 'Compliance assessment and standards evaluation', + }, + { + key: 'driftCompliance', + label: 'Drift Compliance', + description: 'Policy drift analysis and deviation management', + }, + { + key: 'secureScore', + label: 'Microsoft Secure Score', + description: 'Security posture measurement and trends', + }, + { + key: 'licenseManagement', + label: 'License Management', + description: 'License allocation and optimization', + }, + { + key: 'deviceManagement', + label: 'Device Management', + description: 'Device compliance and insights', + }, + { + key: 'conditionalAccess', + label: 'Conditional Access', + description: 'Access control policies and analysis', + }, + { + key: 'infographics', + label: 'Infographic Pages', + description: 'Statistical pages with visual elements between sections', + }, + { + key: 'shadowAI', + label: 'Shadow AI Report', + description: 'AI usage discovery and risk pages from the Shadow AI report', + }, + ] + + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Sections", so it takes the panel without the heading. + const sectionPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Sections + + )} + + Configure which sections to include in your executive report. Changes are reflected in + real-time. + + + + option.value === brandingPresetId) ?? presetOptions[0] + } + onChange={(option) => setPresetOverride(option?.value ?? '')} + /> + + Presets are managed in Settings → Branding + + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + + + 💡 Pro Tip + + + Enable only the sections relevant to your audience to create focused, impactful reports. + At least one section must be enabled. + + + + ) + + return ( + <> + {/* Main Executive Summary Button - Always available */} + {buttonVariant === 'menuItem' ? ( + { + setPreviewOpen(true) + onClickProp?.() + }} + {...other} + > + + + + Executive Summary + + ) : ( + + + + + + )} + + {/* Combined Preview and Configuration Dialog */} + + + + Executive Report - {tenantName} + + + {/* The config rail's stand-in below md, in the title bar because the dialog is + full-screen there and this is the only chrome that stays put. */} + setSectionsOpen(true)} + size="small" + aria-label="Report sections" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + + + + + + + + {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} + + {sectionPanel()} + + + {/* Right Panel - PDF Preview */} + + {isDataLoading ? ( + + Loading Report Data... + + Fetching additional data for comprehensive report generation + + + ) : reportDocument ? ( + + {reportDocument} + + ) : ( + + + Report preview will appear here + + + )} + + + + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > + + + Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} + {sectionOptions.length} + + + + + + + + + {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over + the dialog it is opened from. */} + setSectionsOpen(false)} + title="Report Sections" + size="sm" + contentPadding={0} + aboveModal + > + {sectionPanel({ showHeading: false })} + + + + ); +} diff --git a/src/components/PrivateRoute.js b/src/components/PrivateRoute.js deleted file mode 100644 index d55d560512b3..000000000000 --- a/src/components/PrivateRoute.js +++ /dev/null @@ -1,140 +0,0 @@ -import { ApiGetCall } from "../api/ApiCall.jsx"; -import UnauthenticatedPage from "../pages/unauthenticated.js"; -import LoadingPage from "../pages/loading.js"; -import ApiOfflinePage from "../pages/api-offline.js"; -import SetupGatePage from "./CippComponents/SetupGatePage.jsx"; -import SetupPendingPage from "./CippComponents/SetupPendingPage.jsx"; -import { useState, useEffect } from "react"; -import { useRouter } from "next/router"; -import { rememberSession } from "../utils/auth-session.js"; - -// EasyAuth exposes the signed-in identity in two shapes depending on the host: -// - Static Web Apps: { clientPrincipal: { userDetails, userRoles, ... } } -// - App Service EasyAuth: [ { user_id, user_claims: [...], access_token, ... } ] -// an authenticated session must be detected from either populated shape. -const hasAuthenticatedSession = (data) => - Boolean(data?.clientPrincipal) || (Array.isArray(data) && data.length > 0); - -// Being signed out and being signed in without access need different screens and -// different actions, so the unauthenticated page is told which one it is rather -// than guessing. SESSION means there is no identity at all; PERMISSIONS means a -// valid identity that CIPP won't let through. -export const UNAUTH_SESSION = "session"; -export const UNAUTH_PERMISSIONS = "permissions"; - -export const PrivateRoute = ({ children, routeType }) => { - const router = useRouter(); - const [unauthLatched, setUnauthLatched] = useState(false); - - const session = ApiGetCall({ - url: "/.auth/me", - queryKey: "authmeswa", - refetchOnWindowFocus: true, - staleTime: 120000, // 2 minutes - }); - - // Latch the unauthenticated state so refetches from child components don't flip us - // back to loading. Latch on a request error or a settled session with no identity; - // clear it as soon as an authenticated session (either shape) is seen. - useEffect(() => { - if ( - !session.isLoading && - !session.isFetching && - (session.isError || !hasAuthenticatedSession(session.data)) - ) { - setUnauthLatched(true); - } else if (hasAuthenticatedSession(session.data)) { - setUnauthLatched(false); - } - }, [session.isLoading, session.isFetching, session.isError, session.data]); - - const apiRoles = ApiGetCall({ - url: "/api/me", - queryKey: "authmecipp", - retry: 2, - waiting: session.isSuccess && hasAuthenticatedSession(session.data), - }); - - // Record that a session existed on this device so the sign-in screen can tell - // an expired session from a first visit. - useEffect(() => { - if (hasAuthenticatedSession(session.data)) { - rememberSession(); - } - }, [session.data]); - - // If latched as unauthenticated, always show unauthenticated page - if (unauthLatched) { - return ; - } - - // Check if the session is still loading before determining authentication status - // return loading page here when roles are loading to avoid showing 401 - if ( - session.isLoading || - apiRoles.isLoading || - (hasAuthenticatedSession(session.data) && apiRoles.isPending) || - (apiRoles.isFetching && !apiRoles.data?.clientPrincipal) - ) { - return ; - } - - // Check if the API is offline (404 error from /api/me endpoint) - // Or other network errors that would indicate API is unavailable - if ( - apiRoles?.error?.response?.status === 404 || // API endpoint not found - apiRoles?.error?.response?.status === 502 || // Bad Gateway - apiRoles?.error?.response?.status === 503 || // Service Unavailable - (apiRoles?.isSuccess && !apiRoles?.data) // No client principal data, indicating API might be offline - ) { - return ; - } - - let roles = null; - - if ( - session?.isSuccess && - apiRoles?.isSuccess && - undefined !== apiRoles?.data?.clientPrincipal && - session?.data?.clientPrincipal?.userDetails && - apiRoles?.data?.clientPrincipal?.userDetails && - session?.data?.clientPrincipal?.userDetails !== apiRoles?.data?.clientPrincipal?.userDetails - ) { - // refetch the profile if the user details are different - apiRoles.refetch(); - } - - if (null !== apiRoles?.data?.clientPrincipal && undefined !== apiRoles?.data) { - roles = apiRoles?.data?.clientPrincipal?.userRoles ?? []; - } else if (null === apiRoles?.data?.clientPrincipal || undefined === apiRoles?.data) { - // CIPP has no identity for this caller at all, so there is nothing to deny - return ; - } - if (null === roles) { - return ; - } else { - const blockedRoles = ["anonymous", "authenticated"]; - const userRoles = roles?.filter((role) => !blockedRoles.includes(role)) ?? []; - const isAuthenticated = userRoles.length > 0 && !apiRoles?.error; - const isAdmin = roles?.includes("admin") || roles?.includes("superadmin"); - // from here the identity is real, it just isn't allowed through - if (routeType === "admin" && !isAdmin) { - return ; - } - - if (!isAuthenticated) { - return ; - } - - // Block the whole app until the SAM app is configured: admins get the setup - // wizard full-screen, everyone else a hold page. Strict === false so an absent - // field (prerender, early-return /api/me shapes) never gates. /authredirect is - // the OAuth popup callback for the wizard's SAM-creation step (posts back via - // BroadcastChannel('cipp_auth')) - gating it would hang the sign-in popup. - if (apiRoles.data?.initialSetupComplete === false && router.pathname !== "/authredirect") { - return isAdmin ? : ; - } - - return children; - } -}; diff --git a/src/components/PrivateRoute.jsx b/src/components/PrivateRoute.jsx new file mode 100644 index 000000000000..9ed1c8b268b2 --- /dev/null +++ b/src/components/PrivateRoute.jsx @@ -0,0 +1,140 @@ +import { ApiGetCall } from "../api/ApiCall.jsx"; +import UnauthenticatedPage from "../pages/unauthenticated"; +import LoadingPage from "../pages/loading"; +import ApiOfflinePage from "../pages/api-offline"; +import SetupGatePage from "./CippComponents/SetupGatePage.jsx"; +import SetupPendingPage from "./CippComponents/SetupPendingPage.jsx"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/router"; +import { rememberSession } from "../utils/auth-session.js"; + +// EasyAuth exposes the signed-in identity in two shapes depending on the host: +// - Static Web Apps: { clientPrincipal: { userDetails, userRoles, ... } } +// - App Service EasyAuth: [ { user_id, user_claims: [...], access_token, ... } ] +// an authenticated session must be detected from either populated shape. +const hasAuthenticatedSession = (data) => + Boolean(data?.clientPrincipal) || (Array.isArray(data) && data.length > 0); + +// Being signed out and being signed in without access need different screens and +// different actions, so the unauthenticated page is told which one it is rather +// than guessing. SESSION means there is no identity at all; PERMISSIONS means a +// valid identity that CIPP won't let through. +export const UNAUTH_SESSION = "session"; +export const UNAUTH_PERMISSIONS = "permissions"; + +export const PrivateRoute = ({ children, routeType }) => { + const router = useRouter(); + const [unauthLatched, setUnauthLatched] = useState(false); + + const session = ApiGetCall({ + url: "/.auth/me", + queryKey: "authmeswa", + refetchOnWindowFocus: true, + staleTime: 120000, // 2 minutes + }); + + // Latch the unauthenticated state so refetches from child components don't flip us + // back to loading. Latch on a request error or a settled session with no identity; + // clear it as soon as an authenticated session (either shape) is seen. + useEffect(() => { + if ( + !session.isLoading && + !session.isFetching && + (session.isError || !hasAuthenticatedSession(session.data)) + ) { + setUnauthLatched(true); + } else if (hasAuthenticatedSession(session.data)) { + setUnauthLatched(false); + } + }, [session.isLoading, session.isFetching, session.isError, session.data]); + + const apiRoles = ApiGetCall({ + url: "/api/me", + queryKey: "authmecipp", + retry: 2, + waiting: session.isSuccess && hasAuthenticatedSession(session.data), + }); + + // Record that a session existed on this device so the sign-in screen can tell + // an expired session from a first visit. + useEffect(() => { + if (hasAuthenticatedSession(session.data)) { + rememberSession(); + } + }, [session.data]); + + // If latched as unauthenticated, always show unauthenticated page + if (unauthLatched) { + return ; + } + + // Check if the session is still loading before determining authentication status + // return loading page here when roles are loading to avoid showing 401 + if ( + session.isLoading || + apiRoles.isLoading || + (hasAuthenticatedSession(session.data) && apiRoles.isPending) || + (apiRoles.isFetching && !apiRoles.data?.clientPrincipal) + ) { + return ; + } + + // Check if the API is offline (404 error from /api/me endpoint) + // Or other network errors that would indicate API is unavailable + if ( + apiRoles?.error?.response?.status === 404 || // API endpoint not found + apiRoles?.error?.response?.status === 502 || // Bad Gateway + apiRoles?.error?.response?.status === 503 || // Service Unavailable + (apiRoles?.isSuccess && !apiRoles?.data) // No client principal data, indicating API might be offline + ) { + return ; + } + + let roles = null; + + if ( + session?.isSuccess && + apiRoles?.isSuccess && + undefined !== apiRoles?.data?.clientPrincipal && + session?.data?.clientPrincipal?.userDetails && + apiRoles?.data?.clientPrincipal?.userDetails && + session?.data?.clientPrincipal?.userDetails !== apiRoles?.data?.clientPrincipal?.userDetails + ) { + // refetch the profile if the user details are different + apiRoles.refetch(); + } + + if (null !== apiRoles?.data?.clientPrincipal && undefined !== apiRoles?.data) { + roles = apiRoles?.data?.clientPrincipal?.userRoles ?? []; + } else if (null === apiRoles?.data?.clientPrincipal || undefined === apiRoles?.data) { + // CIPP has no identity for this caller at all, so there is nothing to deny + return ; + } + if (null === roles) { + return ; + } else { + const blockedRoles = ["anonymous", "authenticated"]; + const userRoles = roles?.filter((role) => !blockedRoles.includes(role)) ?? []; + const isAuthenticated = userRoles.length > 0 && !apiRoles?.error; + const isAdmin = roles?.includes("admin") || roles?.includes("superadmin"); + // from here the identity is real, it just isn't allowed through + if (routeType === "admin" && !isAdmin) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + // Block the whole app until the SAM app is configured: admins get the setup + // wizard full-screen, everyone else a hold page. Strict === false so an absent + // field (prerender, early-return /api/me shapes) never gates. /authredirect is + // the OAuth popup callback for the wizard's SAM-creation step (posts back via + // BroadcastChannel('cipp_auth')) - gating it would hang the sign-in popup. + if (apiRoles.data?.initialSetupComplete === false && router.pathname !== "/authredirect") { + return isAdmin ? : ; + } + + return children; + } +}; diff --git a/src/components/ReleaseNotesDialog.js b/src/components/ReleaseNotesDialog.js deleted file mode 100644 index 08811b2286f6..000000000000 --- a/src/components/ReleaseNotesDialog.js +++ /dev/null @@ -1,705 +0,0 @@ -import { - Component, - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react' -import { - Box, - ButtonBase, - IconButton, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Button, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Link, - Stack, - Typography, -} from '@mui/material' -import { visuallyHidden } from '@mui/utils' -import ReactMarkdown from 'react-markdown' -import { useHistoryDismiss } from '../hooks/use-history-dismiss' -import { CippBottomSheet } from './CippComponents/CippBottomSheet' -import { useIsMobileLayout } from '../hooks/use-breakpoint' -import remarkGfm from 'remark-gfm' -import remarkParse from 'remark-parse' -import rehypeRaw from 'rehype-raw' -import { unified } from 'unified' -import packageInfo from '../../public/version.json' -import { ApiGetCall } from '../api/ApiCall' -import { Check, Close, GitHub, KeyboardArrowDown, MoreHoriz } from '@mui/icons-material' -import { CippAutoComplete } from './CippComponents/CippAutocomplete' - -const RELEASE_COOKIE_KEY = 'cipp_release_notice' -const RELEASE_PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden' -const RELEASE_OWNER = 'CyberDrain' -const RELEASE_REPO = 'CIPP' - -const secureFlag = () => { - if (typeof window === 'undefined') { - return '' - } - - return window.location.protocol === 'https:' ? ' Secure' : '' -} - -const getCookie = (name) => { - if (typeof document === 'undefined') { - return null - } - - const cookiePrefix = `${name}=` - const cookies = document.cookie.split('; ') - - for (const cookie of cookies) { - if (cookie.startsWith(cookiePrefix)) { - return decodeURIComponent(cookie.slice(cookiePrefix.length)) - } - } - - return null -} - -const setCookie = (name, value, days = 365) => { - if (typeof document === 'undefined') { - return - } - - const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString() - document.cookie = `${name}=${encodeURIComponent( - value - )}; expires=${expires}; path=/; SameSite=Lax;${secureFlag()}` -} - -const deleteCookie = (name) => { - if (typeof document === 'undefined') { - return - } - - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax;${secureFlag()}` -} - -// Hotfix and maintenance builds publish their own GitHub release (v10.8.1, v10.8.2, ...), so the -// running build's exact tag is both what we show and what we remember as dismissed. Collapsing -// patch releases back to vX.Y.0 here left the dismissal cookie - which stores the tag that was -// actually released - permanently unmatchable, so the dialog reopened on every page load. -// baseTag (vX.Y.0) is what the dialog selects by default so the feature-release notes lead; -// hotfix notes stay reachable via the dropdown. -const buildReleaseMetadata = (version) => { - const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '')) - const [major, minor, patch] = match ? match.slice(1) : ['0', '0', '0'] - const currentTag = `v${major}.${minor}.${patch}` - - return { - currentTag, - baseTag: `v${major}.${minor}.0`, - releaseTag: currentTag, - releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${currentTag}`, - } -} - -const formatReleaseBody = (body) => { - if (!body) { - return '' - } - - return body.replace(/(^|[^\w/])@([a-zA-Z0-9-]+)/g, (match, prefix, username) => { - return `${prefix}[@${username}](https://github.com/${username})` - }) -} - -class MarkdownErrorBoundary extends Component { - constructor(props) { - super(props) - this.state = { hasError: false, error: null } - } - - static getDerivedStateFromError(error) { - return { hasError: true, error } - } - - componentDidCatch(error) { - if (process.env.NODE_ENV !== 'production') { - // eslint-disable-next-line no-console - console.error('Failed to render release notes', error) - } - } - - render() { - if (this.state.hasError) { - return this.props.fallback(this.state.error) - } - - return this.props.children - } -} - -// Which release the dialog *shows*. Hotfix and maintenance builds (v10.8.1, v10.8.2) carry -// only the delta since the feature release, so opening on one tells the user almost nothing -// about what changed. Default to the newest vX.Y.0 instead; the picker still lists every -// release, and dismissal keeps tracking the exact running tag (see buildReleaseMetadata) so -// this can't reintroduce the dialog-reopens-forever bug. -const isFeatureRelease = (tag) => /^v?\d+\.\d+\.0$/.test(String(tag ?? '')) - -const pickDisplayRelease = (catalog, releaseMeta) => - catalog.find((release) => isFeatureRelease(release.releaseTag)) || - catalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || - catalog.find((release) => release.releaseTag === releaseMeta.baseTag) || - catalog[0] - -export const ReleaseNotesDialog = forwardRef((_props, ref) => { - const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), []) - const [isEligible, setIsEligible] = useState(false) - const [open, setOpen] = useState(false) - const [isExpanded, setIsExpanded] = useState(false) - const [manualOpenRequested, setManualOpenRequested] = useState(false) - const [moreActionsOpen, setMoreActionsOpen] = useState(false) - const [releasePickerOpen, setReleasePickerOpen] = useState(false) - // Left unset until the catalog loads so pickDisplayRelease chooses; seeding it with - // the running tag meant a hotfix build always displayed its own thin release notes. - const [selectedReleaseTag, setSelectedReleaseTag] = useState(null) - const hasOpenedRef = useRef(false) - const isMobile = useIsMobileLayout() - - useEffect(() => { - hasOpenedRef.current = false - }, [releaseMeta.releaseTag]) - - useEffect(() => { - // New build -> re-pick from the catalog rather than pinning to this build's tag - setSelectedReleaseTag(null) - }, [releaseMeta.releaseTag]) - - useEffect(() => { - if (typeof window === 'undefined') { - return - } - - const storedValue = getCookie(RELEASE_COOKIE_KEY) - if (storedValue === 'permanently_dismissed') { - window.localStorage.setItem(RELEASE_PERMANENT_HIDE_KEY, 'true') - deleteCookie(RELEASE_COOKIE_KEY) - return - } - - const permanentlyHidden = window.localStorage.getItem(RELEASE_PERMANENT_HIDE_KEY) === 'true' - - if (!permanentlyHidden && storedValue !== releaseMeta.releaseTag) { - setIsEligible(true) - } - }, [releaseMeta.releaseTag]) - - const shouldFetchReleaseList = isEligible || manualOpenRequested || open - - const releaseListQuery = ApiGetCall({ - url: '/api/ListGitHubReleaseNotes', - queryKey: `list-github-release-options`, - waiting: shouldFetchReleaseList, - staleTime: 300000, - }) - - const isReleaseListLoading = releaseListQuery.isLoading || releaseListQuery.isFetching - - const releaseCatalog = useMemo(() => { - return Array.isArray(releaseListQuery.data) ? releaseListQuery.data : [] - }, [releaseListQuery.data]) - - useEffect(() => { - if (!releaseCatalog.length) { - return - } - - if (!selectedReleaseTag) { - setSelectedReleaseTag(pickDisplayRelease(releaseCatalog, releaseMeta)?.releaseTag) - return - } - - const hasSelected = releaseCatalog.some((release) => release.releaseTag === selectedReleaseTag) - - if (!hasSelected) { - const fallbackRelease = pickDisplayRelease(releaseCatalog, releaseMeta) - if (fallbackRelease) { - setSelectedReleaseTag(fallbackRelease.releaseTag) - } - } - }, [releaseCatalog, selectedReleaseTag, releaseMeta]) - - const releaseOptions = useMemo(() => { - const mapped = releaseCatalog.map((release) => { - const tag = release.releaseTag ?? release.tagName - // GitHub release names usually start with the tag ("v10.8.0 - Ramos Melon Fizz"), - // so the parenthetical only earns its width when the name doesn't carry it. - const label = release.name - ? release.name.includes(tag) - ? release.name - : `${release.name} (${tag})` - : tag - return { - label, - value: tag, - addedFields: { - htmlUrl: release.htmlUrl, - publishedAt: release.publishedAt, - }, - } - }) - - if (selectedReleaseTag && !mapped.some((option) => option.value === selectedReleaseTag)) { - mapped.push({ - label: selectedReleaseTag, - value: selectedReleaseTag, - addedFields: { - htmlUrl: releaseMeta.releaseUrl, - publishedAt: null, - }, - }) - } - - return mapped - }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseUrl]) - - const selectedReleaseValue = useMemo(() => { - if (!selectedReleaseTag) { - return null - } - - return ( - releaseOptions.find((option) => option.value === selectedReleaseTag) || { - label: selectedReleaseTag, - value: selectedReleaseTag, - } - ) - }, [releaseOptions, selectedReleaseTag]) - - const handleReleaseChange = useCallback( - (newValue) => { - const nextValue = Array.isArray(newValue) ? newValue[0] : newValue - if (nextValue?.value && nextValue.value !== selectedReleaseTag) { - setSelectedReleaseTag(nextValue.value) - } - }, - [selectedReleaseTag] - ) - - useImperativeHandle(ref, () => ({ - open: () => { - setManualOpenRequested(true) - setOpen(true) - }, - })) - - const selectedReleaseData = useMemo(() => { - if (!selectedReleaseTag) { - return null - } - - return ( - releaseCatalog.find((release) => release.releaseTag === selectedReleaseTag) || - releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || - releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) || - null - ) - }, [releaseCatalog, selectedReleaseTag, releaseMeta]) - - const handleDismissUntilNextRelease = () => { - // Store the same tag the eligibility check reads back - the tag of the build being run, not - // the newest tag on GitHub. Those differ for anyone not on the very latest release, and a - // cookie that can never match means "don't show until next release" never suppresses anything. - window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY) - setCookie(RELEASE_COOKIE_KEY, releaseMeta.releaseTag) - setOpen(false) - setIsExpanded(false) - setManualOpenRequested(false) - setIsEligible(false) - } - - const handleDismissPermanently = () => { - window.localStorage.setItem(RELEASE_PERMANENT_HIDE_KEY, 'true') - deleteCookie(RELEASE_COOKIE_KEY) - setOpen(false) - setIsExpanded(false) - setManualOpenRequested(false) - setIsEligible(false) - } - - const handleRemindLater = () => { - window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY) - setOpen(false) - setIsExpanded(false) - setManualOpenRequested(false) - } - - // Phone back gesture dismisses the dialog instead of navigating the page away — same - // remind-later semantics as the ✕, the backdrop and Esc. - useHistoryDismiss(open, handleRemindLater, isMobile) - - const toggleExpanded = () => { - setIsExpanded((prev) => !prev) - } - - const requestedVersionLabel = - selectedReleaseData?.releaseTag ?? selectedReleaseTag ?? releaseMeta.currentTag - const releaseName = - selectedReleaseData?.name || selectedReleaseValue?.label || `CIPP ${releaseMeta.currentTag}` - const releaseHeading = releaseName || requestedVersionLabel - const releaseBody = typeof selectedReleaseData?.body === 'string' ? selectedReleaseData.body : '' - const releaseUrl = - selectedReleaseData?.htmlUrl ?? - selectedReleaseValue?.addedFields?.htmlUrl ?? - releaseMeta.releaseUrl - const formattedReleaseBody = useMemo(() => formatReleaseBody(releaseBody), [releaseBody]) - const gfmSupport = useMemo(() => { - if (!formattedReleaseBody) { - return { plugins: [remarkGfm], error: null } - } - - try { - unified().use(remarkParse).use(remarkGfm).parse(formattedReleaseBody) - return { plugins: [remarkGfm], error: null } - } catch (err) { - return { plugins: [], error: err } - } - }, [formattedReleaseBody]) - - useEffect(() => { - if (!isEligible || hasOpenedRef.current) { - return - } - - if (releaseCatalog.length || releaseListQuery.error) { - setOpen(true) - hasOpenedRef.current = true - } - }, [isEligible, releaseCatalog.length, releaseListQuery.error]) - - return ( - - - {isMobile ? ( - setReleasePickerOpen(true)} - aria-haspopup="dialog" - sx={{ - minWidth: 0, - flex: 1, - display: 'flex', - alignItems: 'center', - gap: 0.5, - borderRadius: 1, - textAlign: 'left', - justifyContent: 'flex-start', - }} - > - - {selectedReleaseValue?.label ?? 'Release notes'} - - - switch release - - - - ) : ( - - - {`Release notes for ${releaseHeading}`} - - - - - )} - {/* Phones drop the "Remind me next time" button — closing IS remind-later - (onClose runs the same handler) — so the ✕ is the visible way to do it. */} - - - - - - - {releaseListQuery.error ? ( - - We couldn't load additional releases right now. The latest release notes are shown - below. - {releaseListQuery.error?.message ? ` (${releaseListQuery.error.message})` : ''} - - ) : null} - {gfmSupport.error ? ( - - Displaying these release notes without GitHub-flavoured markdown enhancements due to a - parsing issue. Formatting may look different. - - ) : null} - {isReleaseListLoading && !selectedReleaseData ? ( - - - - ) : releaseListQuery.error ? ( - - We couldn't load the release notes right now. You can view them on GitHub instead. - {releaseListQuery.error?.message ? ` (${releaseListQuery.error.message})` : ''} - - ) : ( - - ( - - - We couldn't format these release notes - {error?.message ? ` (${error.message})` : ''}. A plain-text version is shown - below. - - - {releaseBody} - - - )} - > - ( - - ), - img: ({ node, ...props }) => ( - - ), - }} - rehypePlugins={[rehypeRaw]} - remarkPlugins={gfmSupport.plugins} - > - {formattedReleaseBody} - - - - )} - - - - {/* Desktop-only: on phones these two live in the bottom sheet behind the kebab, - the same actions treatment as the rest of the mobile surface. */} - - - - - - - - setMoreActionsOpen(true)} - sx={{ - display: { xs: 'inline-flex', md: 'none' }, - minWidth: 44, - minHeight: 44, - border: 1, - borderColor: 'divider', - borderRadius: 1, - }} - > - - - - - setReleasePickerOpen(false)} - title="Release" - > - - {releaseOptions.map((option) => { - const selected = option.value === selectedReleaseTag - return ( - { - setReleasePickerOpen(false) - if (!selected) handleReleaseChange(option) - }} - > - - {selected && } - - ) - })} - - - setMoreActionsOpen(false)} - title="Release notes" - > - - setMoreActionsOpen(false)} - sx={{ minHeight: 48 }} - > - - - - - - { - setMoreActionsOpen(false) - handleDismissPermanently() - }} - sx={{ minHeight: 48 }} - > - - - - - - - - - ) -}) - -ReleaseNotesDialog.displayName = 'ReleaseNotesDialog' diff --git a/src/components/ReleaseNotesDialog.jsx b/src/components/ReleaseNotesDialog.jsx new file mode 100644 index 000000000000..9a538d08d3bf --- /dev/null +++ b/src/components/ReleaseNotesDialog.jsx @@ -0,0 +1,737 @@ +import { + Component, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { CippIcons } from '../utils/icon-registry' +import { + Box, + ButtonBase, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Link, + Stack, + Typography, +} from '@mui/material' +import { visuallyHidden } from '@mui/utils' +import ReactMarkdown from 'react-markdown' +import { useHistoryDismiss } from '../hooks/use-history-dismiss' +import { CippBottomSheet } from './CippComponents/CippBottomSheet' +import { useIsMobileLayout } from '../hooks/use-breakpoint' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import rehypeRaw from 'rehype-raw' +import { unified } from 'unified' +import packageInfo from '../../public/version.json' +import { ApiGetCall } from '../api/ApiCall' +import { CippAutoComplete } from './CippComponents/CippAutocomplete' + +const RELEASE_COOKIE_KEY = 'cipp_release_notice' +const RELEASE_PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden' +const RELEASE_OWNER = 'CyberDrain' +const RELEASE_REPO = 'CIPP' + +const secureFlag = () => { + if (typeof window === 'undefined') { + return '' + } + + return window.location.protocol === 'https:' ? ' Secure' : '' +} + +const getCookie = (name) => { + if (typeof document === 'undefined') { + return null + } + + const cookiePrefix = `${name}=` + const cookies = document.cookie.split('; ') + + for (const cookie of cookies) { + if (cookie.startsWith(cookiePrefix)) { + return decodeURIComponent(cookie.slice(cookiePrefix.length)) + } + } + + return null +} + +const setCookie = (name, value, days = 365) => { + if (typeof document === 'undefined') { + return + } + + const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString() + document.cookie = `${name}=${encodeURIComponent( + value + )}; expires=${expires}; path=/; SameSite=Lax;${secureFlag()}` +} + +const deleteCookie = (name) => { + if (typeof document === 'undefined') { + return + } + + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax;${secureFlag()}` +} + +// Hotfix and maintenance builds publish their own GitHub release (v10.8.1, v10.8.2, ...), so the +// running build's exact tag is both what we show and what we remember as dismissed. Collapsing +// patch releases back to vX.Y.0 here left the dismissal cookie - which stores the tag that was +// actually released - permanently unmatchable, so the dialog reopened on every page load. +// baseTag (vX.Y.0) is what the dialog selects by default so the feature-release notes lead; +// hotfix notes stay reachable via the dropdown. +const buildReleaseMetadata = (version) => { + const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '')) + const [major, minor, patch] = match ? match.slice(1) : ['0', '0', '0'] + const currentTag = `v${major}.${minor}.${patch}` + + return { + currentTag, + baseTag: `v${major}.${minor}.0`, + releaseTag: currentTag, + releaseUrl: `https://github.com/${RELEASE_OWNER}/${RELEASE_REPO}/releases/tag/${currentTag}`, + } +} + +const formatReleaseBody = (body) => { + if (!body) { + return '' + } + + return body.replace(/(^|[^\w/])@([a-zA-Z0-9-]+)/g, (match, prefix, username) => { + return `${prefix}[@${username}](https://github.com/${username})` + }); +} + +class MarkdownErrorBoundary extends Component { + constructor(props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error) { + return { hasError: true, error } + } + + componentDidCatch(error) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error('Failed to render release notes', error) + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error) + } + + return this.props.children + } +} + +// Which release the dialog *shows*. Hotfix and maintenance builds (v10.8.1, v10.8.2) carry +// only the delta since the feature release, so opening on one tells the user almost nothing +// about what changed. Default to the newest vX.Y.0 instead; the picker still lists every +// release, and dismissal keeps tracking the exact running tag (see buildReleaseMetadata) so +// this can't reintroduce the dialog-reopens-forever bug. +const isFeatureRelease = (tag) => /^v?\d+\.\d+\.0$/.test(String(tag ?? '')) + +const pickDisplayRelease = (catalog, releaseMeta) => + catalog.find((release) => isFeatureRelease(release.releaseTag)) || + catalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + catalog.find((release) => release.releaseTag === releaseMeta.baseTag) || + catalog[0] + +export const ReleaseNotesDialog = forwardRef((_props, ref) => { + const releaseMeta = useMemo(() => buildReleaseMetadata(packageInfo.version), []) + const [isEligible, setIsEligible] = useState(false) + const [open, setOpen] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [manualOpenRequested, setManualOpenRequested] = useState(false) + const [moreActionsOpen, setMoreActionsOpen] = useState(false) + const [releasePickerOpen, setReleasePickerOpen] = useState(false) + // Left unset until the catalog loads so pickDisplayRelease chooses; seeding it with + // the running tag meant a hotfix build always displayed its own thin release notes. + const [selectedReleaseTag, setSelectedReleaseTag] = useState(null) + const hasOpenedRef = useRef(false) + const isMobile = useIsMobileLayout() + + useEffect(() => { + hasOpenedRef.current = false + }, [releaseMeta.releaseTag]) + + useEffect(() => { + // New build -> re-pick from the catalog rather than pinning to this build's tag + setSelectedReleaseTag(null) + }, [releaseMeta.releaseTag]) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + const storedValue = getCookie(RELEASE_COOKIE_KEY) + if (storedValue === 'permanently_dismissed') { + window.localStorage.setItem(RELEASE_PERMANENT_HIDE_KEY, 'true') + deleteCookie(RELEASE_COOKIE_KEY) + return + } + + const permanentlyHidden = window.localStorage.getItem(RELEASE_PERMANENT_HIDE_KEY) === 'true' + + if (!permanentlyHidden && storedValue !== releaseMeta.releaseTag) { + setIsEligible(true) + } + }, [releaseMeta.releaseTag]) + + const shouldFetchReleaseList = isEligible || manualOpenRequested || open + + const releaseListQuery = ApiGetCall({ + url: '/api/ListGitHubReleaseNotes', + queryKey: `list-github-release-options`, + waiting: shouldFetchReleaseList, + staleTime: 300000, + }) + + const isReleaseListLoading = releaseListQuery.isLoading || releaseListQuery.isFetching + + const releaseCatalog = useMemo(() => { + return Array.isArray(releaseListQuery.data) ? releaseListQuery.data : [] + }, [releaseListQuery.data]) + + useEffect(() => { + if (!releaseCatalog.length) { + return + } + + if (!selectedReleaseTag) { + setSelectedReleaseTag(pickDisplayRelease(releaseCatalog, releaseMeta)?.releaseTag) + return + } + + const hasSelected = releaseCatalog.some((release) => release.releaseTag === selectedReleaseTag) + + if (!hasSelected) { + const fallbackRelease = pickDisplayRelease(releaseCatalog, releaseMeta) + if (fallbackRelease) { + setSelectedReleaseTag(fallbackRelease.releaseTag) + } + } + }, [releaseCatalog, selectedReleaseTag, releaseMeta]) + + const releaseOptions = useMemo(() => { + const mapped = releaseCatalog.map((release) => { + const tag = release.releaseTag ?? release.tagName + // GitHub release names usually start with the tag ("v10.8.0 - Ramos Melon Fizz"), + // so the parenthetical only earns its width when the name doesn't carry it. + const label = release.name + ? release.name.includes(tag) + ? release.name + : `${release.name} (${tag})` + : tag + return { + label, + value: tag, + addedFields: { + htmlUrl: release.htmlUrl, + publishedAt: release.publishedAt, + }, + } + }) + + if (selectedReleaseTag && !mapped.some((option) => option.value === selectedReleaseTag)) { + mapped.push({ + label: selectedReleaseTag, + value: selectedReleaseTag, + addedFields: { + htmlUrl: releaseMeta.releaseUrl, + publishedAt: null, + }, + }) + } + + return mapped + }, [releaseCatalog, selectedReleaseTag, releaseMeta.releaseUrl]) + + const selectedReleaseValue = useMemo(() => { + if (!selectedReleaseTag) { + return null + } + + return ( + releaseOptions.find((option) => option.value === selectedReleaseTag) || { + label: selectedReleaseTag, + value: selectedReleaseTag, + } + ) + }, [releaseOptions, selectedReleaseTag]) + + const handleReleaseChange = useCallback( + (newValue) => { + const nextValue = Array.isArray(newValue) ? newValue[0] : newValue + if (nextValue?.value && nextValue.value !== selectedReleaseTag) { + setSelectedReleaseTag(nextValue.value) + } + }, + [selectedReleaseTag] + ) + + useImperativeHandle(ref, () => ({ + open: () => { + setManualOpenRequested(true) + setOpen(true) + }, + })) + + const selectedReleaseData = useMemo(() => { + if (!selectedReleaseTag) { + return null + } + + return ( + releaseCatalog.find((release) => release.releaseTag === selectedReleaseTag) || + releaseCatalog.find((release) => release.releaseTag === releaseMeta.releaseTag) || + releaseCatalog.find((release) => release.releaseTag === releaseMeta.baseTag) || + null + ) + }, [releaseCatalog, selectedReleaseTag, releaseMeta]) + + const handleDismissUntilNextRelease = () => { + // Store the same tag the eligibility check reads back - the tag of the build being run, not + // the newest tag on GitHub. Those differ for anyone not on the very latest release, and a + // cookie that can never match means "don't show until next release" never suppresses anything. + window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY) + setCookie(RELEASE_COOKIE_KEY, releaseMeta.releaseTag) + setOpen(false) + setIsExpanded(false) + setManualOpenRequested(false) + setIsEligible(false) + } + + const handleDismissPermanently = () => { + window.localStorage.setItem(RELEASE_PERMANENT_HIDE_KEY, 'true') + deleteCookie(RELEASE_COOKIE_KEY) + setOpen(false) + setIsExpanded(false) + setManualOpenRequested(false) + setIsEligible(false) + } + + const handleRemindLater = () => { + window.localStorage.removeItem(RELEASE_PERMANENT_HIDE_KEY) + setOpen(false) + setIsExpanded(false) + setManualOpenRequested(false) + } + + // Phone back gesture dismisses the dialog instead of navigating the page away — same + // remind-later semantics as the ✕, the backdrop and Esc. + useHistoryDismiss(open, handleRemindLater, isMobile) + + const toggleExpanded = () => { + setIsExpanded((prev) => !prev) + } + + const requestedVersionLabel = + selectedReleaseData?.releaseTag ?? selectedReleaseTag ?? releaseMeta.currentTag + const releaseName = + selectedReleaseData?.name || selectedReleaseValue?.label || `CIPP ${releaseMeta.currentTag}` + const releaseHeading = releaseName || requestedVersionLabel + const releaseBody = typeof selectedReleaseData?.body === 'string' ? selectedReleaseData.body : '' + const releaseUrl = + selectedReleaseData?.htmlUrl ?? + selectedReleaseValue?.addedFields?.htmlUrl ?? + releaseMeta.releaseUrl + const formattedReleaseBody = useMemo(() => formatReleaseBody(releaseBody), [releaseBody]) + const gfmSupport = useMemo(() => { + if (!formattedReleaseBody) { + return { plugins: [remarkGfm], error: null } + } + + try { + unified().use(remarkParse).use(remarkGfm).parse(formattedReleaseBody) + return { plugins: [remarkGfm], error: null } + } catch (err) { + return { plugins: [], error: err } + } + }, [formattedReleaseBody]) + + useEffect(() => { + if (!isEligible || hasOpenedRef.current) { + return + } + + if (releaseCatalog.length || releaseListQuery.error) { + setOpen(true) + hasOpenedRef.current = true + } + }, [isEligible, releaseCatalog.length, releaseListQuery.error]) + + // Phones always go fullscreen — a centred md dialog wastes the viewport and + // fights the bottom-sheet pickers. Desktop Expand still toggles fullScreen. + const fullScreen = isExpanded || isMobile + + return ( + + + {isMobile ? ( + setReleasePickerOpen(true)} + aria-haspopup="dialog" + sx={{ + minWidth: 0, + flex: 1, + display: 'flex', + alignItems: 'center', + gap: 0.5, + borderRadius: 1, + textAlign: 'left', + justifyContent: 'flex-start', + }} + > + + {selectedReleaseValue?.label ?? 'Release notes'} + + + switch release + + + + ) : ( + + + {`Release notes for ${releaseHeading}`} + + + + + )} + {/* Phones drop the "Remind me next time" button — closing IS remind-later + (onClose runs the same handler) — so the ✕ is the visible way to do it. */} + + + + + + + {releaseListQuery.error ? ( + + We couldn't load additional releases right now. The latest release notes are shown + below. + {releaseListQuery.error?.message ? ` (${releaseListQuery.error.message})` : ''} + + ) : null} + {gfmSupport.error ? ( + + Displaying these release notes without GitHub-flavoured markdown enhancements due to a + parsing issue. Formatting may look different. + + ) : null} + {isReleaseListLoading && !selectedReleaseData ? ( + + + + ) : releaseListQuery.error ? ( + + We couldn't load the release notes right now. You can view them on GitHub instead. + {releaseListQuery.error?.message ? ` (${releaseListQuery.error.message})` : ''} + + ) : ( + + ( + + + We couldn't format these release notes + {error?.message ? ` (${error.message})` : ''}. A plain-text version is shown + below. + + + {releaseBody} + + + )} + > + ( + + ), + img: ({ node, ...props }) => ( + + ), + }} + rehypePlugins={[rehypeRaw]} + remarkPlugins={gfmSupport.plugins} + > + {formattedReleaseBody} + + + + )} + + + + {/* Desktop-only: on phones these two live in the bottom sheet behind the kebab, + the same actions treatment as the rest of the mobile surface. */} + + + + + + + + setMoreActionsOpen(true)} + sx={{ + display: { xs: 'inline-flex', md: 'none' }, + minWidth: 44, + minHeight: 44, + border: 1, + borderColor: 'divider', + borderRadius: 1, + }} + > + + + + + setReleasePickerOpen(false)} + title="Release" + > + + {releaseOptions.map((option) => { + const selected = option.value === selectedReleaseTag + return ( + { + setReleasePickerOpen(false) + if (!selected) handleReleaseChange(option) + }} + > + + {selected && } + + ); + })} + + + setMoreActionsOpen(false)} + title="Release notes" + > + + setMoreActionsOpen(false)} + sx={{ minHeight: 48 }} + > + + + + + + { + setMoreActionsOpen(false) + handleDismissPermanently() + }} + sx={{ minHeight: 48 }} + > + + + + + + + + + ); +}) + +ReleaseNotesDialog.displayName = 'ReleaseNotesDialog' diff --git a/src/components/ReportBuilder/ReportBuilderBlocks.jsx b/src/components/ReportBuilder/ReportBuilderBlocks.jsx index caa397191f7d..a2f9ba0305b3 100644 --- a/src/components/ReportBuilder/ReportBuilderBlocks.jsx +++ b/src/components/ReportBuilder/ReportBuilderBlocks.jsx @@ -1,15 +1,5 @@ import { Box, Chip, IconButton, Stack, TextField, Tooltip, Typography } from '@mui/material' -import { - Add, - ArrowDownward, - ArrowUpward, - BarChart as BarChartIcon, - Delete, - HorizontalRule, - Speed, - ViewCarousel, - Assessment, -} from '@mui/icons-material' +import { CippIcons } from '../../utils/icon-registry' import CippButtonCard from '../CippCards/CippButtonCard' import { CippAutoComplete } from '../CippComponents/CippAutocomplete' import { CHART_KINDS, COVER_STOCK_OPTIONS } from '../CippPdf' @@ -31,11 +21,11 @@ export const STRUCTURED_BLOCK_TYPES = [ ] const BLOCK_META = { - chart: { label: 'Chart', Icon: BarChartIcon, colour: 'primary' }, - scorecard: { label: 'Score Cards', Icon: Assessment, colour: 'success' }, - progress: { label: 'Progress Bars', Icon: Speed, colour: 'info' }, - hero: { label: 'Section Divider', Icon: ViewCarousel, colour: 'warning' }, - pagebreak: { label: 'Page Break', Icon: HorizontalRule, colour: 'default' }, + chart: { label: 'Chart', Icon: CippIcons.BarChart, colour: 'primary' }, + scorecard: { label: 'Score Cards', Icon: CippIcons.Assessment, colour: 'success' }, + progress: { label: 'Progress Bars', Icon: CippIcons.Speed, colour: 'info' }, + hero: { label: 'Section Divider', Icon: CippIcons.ViewCarousel, colour: 'warning' }, + pagebreak: { label: 'Page Break', Icon: CippIcons.HorizontalRule, colour: 'default' }, } export const isStructuredBlock = (type) => Object.prototype.hasOwnProperty.call(BLOCK_META, type) @@ -105,7 +95,9 @@ const BlockShell = ({ block, index, totalBlocks, onRemove, onMoveUp, onMoveDown, title={ {Icon ? : null} - + {block.title || meta.label} + onMoveUp(index)} disabled={index === 0} > - + @@ -142,7 +136,7 @@ const BlockShell = ({ block, index, totalBlocks, onRemove, onMoveUp, onMoveDown, onClick={() => onMoveDown(index)} disabled={index === totalBlocks - 1} > - + @@ -153,7 +147,7 @@ const BlockShell = ({ block, index, totalBlocks, onRemove, onMoveUp, onMoveDown, aria-label="Remove block" onClick={() => onRemove(index)} > - + @@ -161,7 +155,7 @@ const BlockShell = ({ block, index, totalBlocks, onRemove, onMoveUp, onMoveDown, > {children} - ) + ); } /** @@ -183,7 +177,9 @@ const RowsEditor = ({ rows, columns, onChange, addLabel = 'Add row', minRows = 1 return ( {rows.map((row, rowIndex) => ( - + {columns.map((column) => ( remove(rowIndex)} disabled={rows.length <= minRows} > - + @@ -212,14 +208,16 @@ const RowsEditor = ({ rows, columns, onChange, addLabel = 'Add row', minRows = 1 ))} - + - + {addLabel} - ) + ); } const TitleField = ({ block, index, onUpdate, label = 'Block title', helperText }) => ( @@ -329,13 +327,15 @@ export const ScorecardBlockCard = ({ block, index, onUpdate, ...shell }) => { addLabel="Add card" /> {stats.length > 4 ? ( - + More than four cards on a row get too narrow to read in the PDF. ) : null} - ) + ); } /* ── Progress bars ───────────────────────────────────────── */ @@ -366,7 +366,9 @@ export const HeroBlockCard = ({ block, index, onUpdate, ...shell }) => { return ( - + Takes a full page of its own, with the background image bleeding to the paper edge. @@ -418,14 +420,16 @@ export const HeroBlockCard = ({ block, index, onUpdate, ...shell }) => { - ) + ); } /* ── Page break ──────────────────────────────────────────── */ export const PageBreakBlockCard = ({ block, index, ...shell }) => ( - + Everything after this starts on a new page. diff --git a/src/components/ReportBuilder/ReportBuilderPDF.js b/src/components/ReportBuilder/ReportBuilderPDF.jsx similarity index 100% rename from src/components/ReportBuilder/ReportBuilderPDF.js rename to src/components/ReportBuilder/ReportBuilderPDF.jsx diff --git a/src/components/ShadowAIReportButton.js b/src/components/ShadowAIReportButton.js deleted file mode 100644 index 82b13955ca47..000000000000 --- a/src/components/ShadowAIReportButton.js +++ /dev/null @@ -1,863 +0,0 @@ -import { useMemo, useState } from 'react' -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - IconButton, - Paper, - Stack, - SvgIcon, - Switch, - Tooltip, - Typography, -} from '@mui/material' -import { Close, Download, PictureAsPdf, Settings } from '@mui/icons-material' -import { CippPdfPreview } from './CippPdf/CippPdfPreview' -import { CippOffCanvas } from './CippComponents/CippOffCanvas' -import { useReportVariables } from './CippPdf/useReportVariables' -import { useBrandingSettings } from './CippPdf/useBrandingSettings' -import { - Bold, - BulletList, - Columns, - ContentPage, - DataTable, - DonutChart, - HeroPage, - InfoBox, - Paragraph, - ReportDocument, - Section, - StatRow, -} from './CippPdf' - -// Risk bands keep the semantic red-to-green scale rather than the brand series: red has to mean -// high risk whatever colours the MSP picked. -const RISK_COLOURS = { - high: '#EF4444', - medium: '#F59E0B', - low: '#3B82F6', - informational: '#10B981', -} -const riskColor = (risk) => RISK_COLOURS[String(risk).toLowerCase()] ?? '#A0AEC0' - -// Executive Shadow AI report. Written as a pages fragment with no theme, stylesheet or page -// furniture of its own, which is what lets the executive report drop the same pages into its own -// document and have them come out branded identically (see ExecutiveReportButton). -export const ShadowAIReportPages = ({ - tenantName, - data, - sectionConfig = { - executiveSummary: true, - infographics: true, - background: true, - riskLevels: true, - sanctionedTools: true, - detectedSoftware: true, - entraApplications: true, - recommendations: true, - }, -}) => { - const summary = data?.summary ?? {} - const detectedApps = data?.detectedApps ?? [] - const consentedApps = data?.consentedApps ?? [] - const topTools = data?.topTools ?? [] - const byRisk = data?.byRisk ?? [] - - const riskAreas = [ - { - title: 'Data Leakage', - color: '#EF4444', - text: 'Customer records, credentials, source code and financials pasted into consumer AI tools may be retained by the provider and used to train future models, permanently placing them outside your control. Every prompt is a data transfer to a third party.', - }, - { - title: 'Compliance & Legal Exposure', - color: '#F59E0B', - text: 'Processing personal data through unvetted AI services can breach GDPR, HIPAA and industry-specific regulations, and undermines contractual confidentiality commitments made to customers.', - }, - { - title: 'Excessive Application Permissions', - color: '#D69E2E', - text: 'AI meeting assistants and productivity plugins often request broad access to mailboxes, calendars and files. A single user consent can expose organization-wide data to a third-party service, and that access persists until the consent is revoked.', - }, - { - title: 'Unreliable Output in Business Processes', - color: '#3B82F6', - text: 'AI-generated content flows into quotes, contracts and customer communication without review. Errors produced by unmanaged tools are difficult to trace because the organization does not know the tools are in use.', - }, - ] - - const riskLevels = [ - { - name: 'High', - text: 'Consumer tools that train on submitted data, retain prompts indefinitely, or operate from jurisdictions without adequate data protection. Content pasted into these tools should be treated as disclosed to an unvetted third party.', - }, - { - name: 'Medium', - text: 'Tools with business-grade privacy options that are typically used through personal, unmanaged accounts. Risk depends heavily on the plan and account type in use.', - }, - { - name: 'Low', - text: 'Tools with enterprise controls, contractual data-processing terms and no training on customer data when configured correctly.', - }, - { - name: 'Informational', - text: 'Company sanctioned tools that have been explicitly approved for use in this tenant. They remain in the report for visibility but no longer contribute to the risk figures.', - }, - ] - - const recommendations = [ - { - label: 'Review & Decide:', - text: 'Evaluate each detected tool with stakeholders and decide whether it should be sanctioned, replaced with an approved alternative, or blocked.', - }, - { - label: 'Sanction Approved Tools:', - text: 'Maintain a list of company sanctioned tools so future reports separate approved AI use from true shadow AI.', - }, - { - label: 'Offer an Alternative:', - text: 'Provide a sanctioned option such as Microsoft 365 Copilot before blocking popular tools - blocking without an alternative drives usage to personal devices.', - }, - { - label: 'Restrict Consent:', - text: 'Require admin approval for unverified applications in Entra ID so new AI services cannot access company data through user consent.', - }, - { - label: 'Block & Monitor:', - text: 'Deploy Conditional Access and Defender for Cloud Apps policies to block or monitor unsanctioned AI web services.', - }, - { - label: 'Extend DLP:', - text: 'Cover generative AI endpoints with data loss prevention policies to stop sensitive data from being pasted into chat prompts.', - }, - { - label: 'Train Users:', - text: 'Publish an acceptable AI use policy and train users on what data may never be shared with AI tools.', - }, - { - label: 'Review Monthly:', - text: 'Re-run this report on a regular cadence - new AI tools appear in tenants within days of release.', - }, - ] - - const detectedRows = detectedApps.slice(0, 18) - const consentedRows = consentedApps.slice(0, 18) - - // Distinct sanctioned tools across both sources, with their footprint - const sanctionedMap = {} - for (const app of detectedApps) { - if (app.status !== 'Sanctioned') continue - if (!sanctionedMap[app.aiTool]) { - sanctionedMap[app.aiTool] = { - tool: app.aiTool, - vendor: app.vendor, - category: app.category, - devices: 0, - users: 0, - } - } - sanctionedMap[app.aiTool].devices += app.deviceCount ?? 0 - } - for (const app of consentedApps) { - if (app.status !== 'Sanctioned') continue - if (!sanctionedMap[app.aiTool]) { - sanctionedMap[app.aiTool] = { - tool: app.aiTool, - vendor: app.vendor, - category: app.category, - devices: 0, - users: 0, - } - } - sanctionedMap[app.aiTool].users += app.activeUsersLast7Days ?? 0 - } - const sanctionedToolsList = Object.values(sanctionedMap).slice(0, 18) - - return ( - <> - {/* AI SUMMARY. Not "Executive Summary": these pages are appended to the executive report, - which opens with a page of that name, so two unrelated sections carried the same - heading. */} - {sectionConfig.executiveSummary && ( - -
    - - This report identifies the artificial intelligence tools discovered in the{' '} - {tenantName || 'your organization'} environment, combining software - inventory from managed devices (Intune) with cloud application consent data from Entra - ID. Each tool is matched against a curated catalog of known AI services and assigned a - risk level based on its data handling practices. - - - Tools that have been explicitly approved are marked as company sanctioned and report - the Informational risk level. Everything else represents shadow AI: tools adopted by - employees without review or approval, whose handling of company data is unknown. - -
    - -
    - -
    - - {topTools.length > 0 && ( -
    - ({ - ...tool, - status: tool.status ?? 'Unsanctioned', - }))} - /> -
    - )} -
    - )} - - {/* CHAPTER SPLITTER */} - {sectionConfig.infographics && ( - - of knowledge workers already{'\n'} - use generative AI at work -{'\n'} - most without their employer knowing - - } - footerText={ - <> - Visibility is the first step{'\n'} - to control - - } - /> - )} - - {/* UNDERSTANDING SHADOW AI */} - {sectionConfig.background && ( - -
    - - Shadow AI is the use of artificial intelligence tools by employees without the - knowledge or approval of the organization - the AI-era equivalent of shadow IT. - Because most AI tools are free, browser-based and immediately useful, adoption happens - quietly and quickly: an employee pastes a customer email into a chatbot to draft a - reply, uploads a spreadsheet for analysis, or installs an AI notetaker that joins - every meeting. - - - The goal of a shadow AI program is not zero AI usage, but zero unsanctioned usage. - Every tool in this report should end up either approved and managed, or replaced and - blocked. The four risk areas below explain why unmanaged usage deserves attention. - -
    - -
    - {riskAreas.map((area) => ( - - {area.text} - - ))} -
    -
    - )} - - {/* RISK LEVELS & DISTRIBUTION */} - {sectionConfig.riskLevels && ( - -
    - - Detected tools are matched against a curated catalog of known AI services, each - carrying a risk classification based on its data handling practices, account model and - enterprise controls. Marking a tool as company sanctioned overrides its catalog risk - with the Informational level, so the figures below reflect only unapproved use. - -
    - - ({ - label: item.risk, - value: item.tools, - colour: riskColor(item.risk), - }))} - /> - -
    - {[0, 2].map((start) => ( - - {riskLevels.slice(start, start + 2).map((level) => ( - - {level.text} - - ))} - - ))} -
    -
    - )} - - {/* SANCTIONED TOOLS */} - {sectionConfig.sanctionedTools && sanctionedToolsList.length > 0 && ( - -
    - - The tools listed below are permitted in this environment. They are allowed either - because a business justification exists for their use, or because the system - administrator has explicitly approved these tools for deployment. Sanctioned tools - report the Informational risk level and are excluded from the shadow AI risk figures - in this report; they remain listed for visibility into where AI is used across the - organization. - - - Approval is not permanent: sanctioned tools should be reviewed periodically to confirm - that the plan in use, the vendor's data handling terms and the business justification - still hold. - -
    - -
    - -
    -
    - )} - - {/* DETECTED SOFTWARE */} - {sectionConfig.detectedSoftware && ( - -
    - - The following AI applications were detected in the software inventory of managed - devices - {detectedApps.length > detectedRows.length - ? `, showing the top ${detectedRows.length} of ${detectedApps.length} entries` - : ''} - . Device counts indicate how widely each application has spread through the - environment. - -
    - -
    - riskColor(row.risk) }, - { header: 'Status', key: 'status', width: 2.5 }, - { header: 'Devices', key: 'deviceCount', width: 1.5 }, - ]} - rows={detectedRows} - /> -
    -
    - )} - - {/* ENTRA APPLICATIONS */} - {sectionConfig.entraApplications && ( - -
    - - The following AI services are registered as applications in the tenant, including any - permissions users have consented to - {consentedApps.length > consentedRows.length - ? `, showing the top ${consentedRows.length} of ${consentedApps.length} entries` - : ''} - . The consent date shows when each service first gained a foothold in the environment. - -
    - -
    - riskColor(row.risk) }, - { header: 'Status', key: 'status', width: 2.5 }, - { header: 'Users (7d)', key: 'activeUsersLast7Days', width: 2 }, - { header: 'First Consented', key: 'firstConsented', width: 2.5 }, - ]} - rows={consentedRows.map((app) => ({ - ...app, - firstConsented: app.firstConsentedDateTime - ? new Date(app.firstConsentedDateTime).toLocaleDateString() - : 'Unknown', - }))} - /> -
    -
    - )} - - {/* CHAPTER SPLITTER */} - {sectionConfig.infographics && ( - - employees shares sensitive work data - {'\n'} - with AI tools without approval - - } - footerText={ - <> - Sanctioned alternatives keep{'\n'} - your data under contract - - } - /> - )} - - {/* RECOMMENDATIONS */} - {sectionConfig.recommendations && ( - -
    - - A structured response to shadow AI combines approval of useful tools with controls on - the rest. The following actions are recommended based on the findings in this report: - -
    - -
    - -
    - -
    - - The AI tool landscape changes quickly and new tools appear in tenants within days of - release. We recommend re-running this assessment monthly and reviewing newly detected - tools against your acceptable AI use policy. - -
    -
    - )} - - ) -} - -// Exported so the branding preview can render this report against sample data, and so tests can -// render it to a real PDF. -export const ShadowAIReportDocument = ({ tenantName, brandingSettings, variables, ...props }) => ( - - - -) - -const sectionOptions = [ - { - key: 'coverPage', - label: 'Cover Page', - description: 'Branded title page with tenant name and date', - }, - { - key: 'executiveSummary', - label: 'Executive Summary', - description: 'High-level overview, usage statistics and top tools', - }, - { - key: 'infographics', - label: 'Infographic Pages', - description: 'Statistical pages with visual elements between sections', - }, - { - key: 'background', - label: 'Understanding Shadow AI', - description: 'Explains shadow AI and its key risk areas', - }, - { - key: 'riskLevels', - label: 'Risk Levels & Distribution', - description: 'Risk methodology and distribution chart', - }, - { - key: 'sanctionedTools', - label: 'Sanctioned Tools', - description: 'Company approved AI tools and their footprint', - }, - { - key: 'detectedSoftware', - label: 'AI Software (Intune)', - description: 'AI applications found on managed devices', - }, - { - key: 'entraApplications', - label: 'AI Applications (Entra)', - description: 'AI services with consented permissions in Entra ID', - }, - { - key: 'recommendations', - label: 'Recommendations', - description: 'Action plan for managing shadow AI', - }, -] - -export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { - const brandingSettings = useBrandingSettings() - const variables = useReportVariables() - const [previewOpen, setPreviewOpen] = useState(false) - // Below md the 320px config rail would leave the preview about 70px wide, so it moves into - // a drawer and the preview takes the whole dialog. Same treatment as the executive report. - const [sectionsOpen, setSectionsOpen] = useState(false) - const [sectionConfig, setSectionConfig] = useState({ - coverPage: true, - executiveSummary: true, - infographics: true, - background: true, - riskLevels: true, - sanctionedTools: true, - detectedSoftware: true, - entraApplications: true, - recommendations: true, - }) - - const handleSectionToggle = (sectionKey) => { - setSectionConfig((prev) => { - const enabledSections = Object.values(prev).filter(Boolean).length - // Keep at least one section enabled - if (prev[sectionKey] && enabledSections === 1) { - return prev - } - return { ...prev, [sectionKey]: !prev[sectionKey] } - }) - } - - const fileName = `Shadow_AI_Report_${String(tenantName).replace(/[^a-zA-Z0-9]/g, '_')}_${ - new Date().toISOString().split('T')[0] - }.pdf` - - // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own - // header already says "Report Sections", so it takes the panel without the heading. - const sectionPanel = ({ showHeading = true } = {}) => ( - - {showHeading && ( - - - Report Sections - - )} - - Configure which sections to include in your Shadow AI report. Changes are reflected in - real-time. - - - - {sectionOptions.map((option) => ( - handleSectionToggle(option.key)} - sx={{ - p: 1.5, - border: '1px solid', - borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', - bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - display: 'flex', - alignItems: 'center', - '&:hover': { - borderColor: 'primary.main', - bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', - }, - }} - > - { - event.stopPropagation() - handleSectionToggle(option.key) - }} - onClick={(event) => event.stopPropagation()} - color="primary" - size="small" - disabled={ - sectionConfig[option.key] && - Object.values(sectionConfig).filter(Boolean).length === 1 - } - /> - - - {option.label} - - - {option.description} - - - - ))} - - - ) - - const reportDocument = useMemo(() => { - if (!previewOpen) return null - return ( - - ) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [previewOpen, tenantName, data, brandingSettings, variables, JSON.stringify(sectionConfig)]) - - return ( - <> - - - - - - - setPreviewOpen(false)} - maxWidth="xl" - fullWidth - sx={{ - '& .MuiDialog-paper': { - // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows. - height: { xs: '100dvh', md: '95vh' }, - maxHeight: { xs: '100dvh', md: '95vh' }, - }, - }} - > - - - Shadow AI Report - {tenantName} - - - {/* The config rail's stand-in below md, in the title bar because the dialog is - full-screen there and this is the only chrome that stays put. */} - setSectionsOpen(true)} - size="small" - aria-label="Report sections" - sx={{ display: { xs: 'inline-flex', md: 'none' } }} - > - - - setPreviewOpen(false)} - size="small" - aria-label="Close preview" - > - - - - - - {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} - - {sectionPanel()} - - - {/* Right Panel - PDF Preview */} - - {reportDocument && ( - - {reportDocument} - - )} - - - :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, - }} - > - - - Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} - {sectionOptions.length} - - - - - - - - {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over - the dialog it is opened from. */} - setSectionsOpen(false)} - title="Report Sections" - size="sm" - contentPadding={0} - aboveModal - > - {sectionPanel({ showHeading: false })} - - - - ) -} \ No newline at end of file diff --git a/src/components/ShadowAIReportButton.jsx b/src/components/ShadowAIReportButton.jsx new file mode 100644 index 000000000000..2fd6e5f018aa --- /dev/null +++ b/src/components/ShadowAIReportButton.jsx @@ -0,0 +1,880 @@ +import { useMemo, useState } from 'react' +import { CippIcons } from '../utils/icon-registry' +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Paper, + Stack, + SvgIcon, + Switch, + Tooltip, + Typography, +} from '@mui/material' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' +import { CippOffCanvas } from './CippComponents/CippOffCanvas' +import { useReportVariables } from './CippPdf/useReportVariables' +import { useBrandingSettings } from './CippPdf/useBrandingSettings' +import { + Bold, + BulletList, + Columns, + ContentPage, + DataTable, + DonutChart, + HeroPage, + InfoBox, + Paragraph, + ReportDocument, + Section, + StatRow, +} from './CippPdf' + +// Risk bands keep the semantic red-to-green scale rather than the brand series: red has to mean +// high risk whatever colours the MSP picked. +const RISK_COLOURS = { + high: '#EF4444', + medium: '#F59E0B', + low: '#3B82F6', + informational: '#10B981', +} +const riskColor = (risk) => RISK_COLOURS[String(risk).toLowerCase()] ?? '#A0AEC0' + +// Executive Shadow AI report. Written as a pages fragment with no theme, stylesheet or page +// furniture of its own, which is what lets the executive report drop the same pages into its own +// document and have them come out branded identically (see ExecutiveReportButton). +export const ShadowAIReportPages = ({ + tenantName, + data, + sectionConfig = { + executiveSummary: true, + infographics: true, + background: true, + riskLevels: true, + sanctionedTools: true, + detectedSoftware: true, + entraApplications: true, + recommendations: true, + }, +}) => { + const summary = data?.summary ?? {} + const detectedApps = data?.detectedApps ?? [] + const consentedApps = data?.consentedApps ?? [] + const topTools = data?.topTools ?? [] + const byRisk = data?.byRisk ?? [] + + const riskAreas = [ + { + title: 'Data Leakage', + color: '#EF4444', + text: 'Customer records, credentials, source code and financials pasted into consumer AI tools may be retained by the provider and used to train future models, permanently placing them outside your control. Every prompt is a data transfer to a third party.', + }, + { + title: 'Compliance & Legal Exposure', + color: '#F59E0B', + text: 'Processing personal data through unvetted AI services can breach GDPR, HIPAA and industry-specific regulations, and undermines contractual confidentiality commitments made to customers.', + }, + { + title: 'Excessive Application Permissions', + color: '#D69E2E', + text: 'AI meeting assistants and productivity plugins often request broad access to mailboxes, calendars and files. A single user consent can expose organization-wide data to a third-party service, and that access persists until the consent is revoked.', + }, + { + title: 'Unreliable Output in Business Processes', + color: '#3B82F6', + text: 'AI-generated content flows into quotes, contracts and customer communication without review. Errors produced by unmanaged tools are difficult to trace because the organization does not know the tools are in use.', + }, + ] + + const riskLevels = [ + { + name: 'High', + text: 'Consumer tools that train on submitted data, retain prompts indefinitely, or operate from jurisdictions without adequate data protection. Content pasted into these tools should be treated as disclosed to an unvetted third party.', + }, + { + name: 'Medium', + text: 'Tools with business-grade privacy options that are typically used through personal, unmanaged accounts. Risk depends heavily on the plan and account type in use.', + }, + { + name: 'Low', + text: 'Tools with enterprise controls, contractual data-processing terms and no training on customer data when configured correctly.', + }, + { + name: 'Informational', + text: 'Company sanctioned tools that have been explicitly approved for use in this tenant. They remain in the report for visibility but no longer contribute to the risk figures.', + }, + ] + + const recommendations = [ + { + label: 'Review & Decide:', + text: 'Evaluate each detected tool with stakeholders and decide whether it should be sanctioned, replaced with an approved alternative, or blocked.', + }, + { + label: 'Sanction Approved Tools:', + text: 'Maintain a list of company sanctioned tools so future reports separate approved AI use from true shadow AI.', + }, + { + label: 'Offer an Alternative:', + text: 'Provide a sanctioned option such as Microsoft 365 Copilot before blocking popular tools - blocking without an alternative drives usage to personal devices.', + }, + { + label: 'Restrict Consent:', + text: 'Require admin approval for unverified applications in Entra ID so new AI services cannot access company data through user consent.', + }, + { + label: 'Block & Monitor:', + text: 'Deploy Conditional Access and Defender for Cloud Apps policies to block or monitor unsanctioned AI web services.', + }, + { + label: 'Extend DLP:', + text: 'Cover generative AI endpoints with data loss prevention policies to stop sensitive data from being pasted into chat prompts.', + }, + { + label: 'Train Users:', + text: 'Publish an acceptable AI use policy and train users on what data may never be shared with AI tools.', + }, + { + label: 'Review Monthly:', + text: 'Re-run this report on a regular cadence - new AI tools appear in tenants within days of release.', + }, + ] + + const detectedRows = detectedApps.slice(0, 18) + const consentedRows = consentedApps.slice(0, 18) + + // Distinct sanctioned tools across both sources, with their footprint + const sanctionedMap = {} + for (const app of detectedApps) { + if (app.status !== 'Sanctioned') continue + if (!sanctionedMap[app.aiTool]) { + sanctionedMap[app.aiTool] = { + tool: app.aiTool, + vendor: app.vendor, + category: app.category, + devices: 0, + users: 0, + } + } + sanctionedMap[app.aiTool].devices += app.deviceCount ?? 0 + } + for (const app of consentedApps) { + if (app.status !== 'Sanctioned') continue + if (!sanctionedMap[app.aiTool]) { + sanctionedMap[app.aiTool] = { + tool: app.aiTool, + vendor: app.vendor, + category: app.category, + devices: 0, + users: 0, + } + } + sanctionedMap[app.aiTool].users += app.activeUsersLast7Days ?? 0 + } + const sanctionedToolsList = Object.values(sanctionedMap).slice(0, 18) + + return ( + <> + {/* AI SUMMARY. Not "Executive Summary": these pages are appended to the executive report, + which opens with a page of that name, so two unrelated sections carried the same + heading. */} + {sectionConfig.executiveSummary && ( + +
    + + This report identifies the artificial intelligence tools discovered in the{' '} + {tenantName || 'your organization'} environment, combining software + inventory from managed devices (Intune) with cloud application consent data from Entra + ID. Each tool is matched against a curated catalog of known AI services and assigned a + risk level based on its data handling practices. + + + Tools that have been explicitly approved are marked as company sanctioned and report + the Informational risk level. Everything else represents shadow AI: tools adopted by + employees without review or approval, whose handling of company data is unknown. + +
    + +
    + +
    + + {topTools.length > 0 && ( +
    + ({ + ...tool, + status: tool.status ?? 'Unsanctioned', + }))} + /> +
    + )} +
    + )} + + {/* CHAPTER SPLITTER */} + {sectionConfig.infographics && ( + + of knowledge workers already{'\n'} + use generative AI at work -{'\n'} + most without their employer knowing + + } + footerText={ + <> + Visibility is the first step{'\n'} + to control + + } + /> + )} + + {/* UNDERSTANDING SHADOW AI */} + {sectionConfig.background && ( + +
    + + Shadow AI is the use of artificial intelligence tools by employees without the + knowledge or approval of the organization - the AI-era equivalent of shadow IT. + Because most AI tools are free, browser-based and immediately useful, adoption happens + quietly and quickly: an employee pastes a customer email into a chatbot to draft a + reply, uploads a spreadsheet for analysis, or installs an AI notetaker that joins + every meeting. + + + The goal of a shadow AI program is not zero AI usage, but zero unsanctioned usage. + Every tool in this report should end up either approved and managed, or replaced and + blocked. The four risk areas below explain why unmanaged usage deserves attention. + +
    + +
    + {riskAreas.map((area) => ( + + {area.text} + + ))} +
    +
    + )} + + {/* RISK LEVELS & DISTRIBUTION */} + {sectionConfig.riskLevels && ( + +
    + + Detected tools are matched against a curated catalog of known AI services, each + carrying a risk classification based on its data handling practices, account model and + enterprise controls. Marking a tool as company sanctioned overrides its catalog risk + with the Informational level, so the figures below reflect only unapproved use. + +
    + + ({ + label: item.risk, + value: item.tools, + colour: riskColor(item.risk), + }))} + /> + +
    + {[0, 2].map((start) => ( + + {riskLevels.slice(start, start + 2).map((level) => ( + + {level.text} + + ))} + + ))} +
    +
    + )} + + {/* SANCTIONED TOOLS */} + {sectionConfig.sanctionedTools && sanctionedToolsList.length > 0 && ( + +
    + + The tools listed below are permitted in this environment. They are allowed either + because a business justification exists for their use, or because the system + administrator has explicitly approved these tools for deployment. Sanctioned tools + report the Informational risk level and are excluded from the shadow AI risk figures + in this report; they remain listed for visibility into where AI is used across the + organization. + + + Approval is not permanent: sanctioned tools should be reviewed periodically to confirm + that the plan in use, the vendor's data handling terms and the business justification + still hold. + +
    + +
    + +
    +
    + )} + + {/* DETECTED SOFTWARE */} + {sectionConfig.detectedSoftware && ( + +
    + + The following AI applications were detected in the software inventory of managed + devices + {detectedApps.length > detectedRows.length + ? `, showing the top ${detectedRows.length} of ${detectedApps.length} entries` + : ''} + . Device counts indicate how widely each application has spread through the + environment. + +
    + +
    + riskColor(row.risk) }, + { header: 'Status', key: 'status', width: 2.5 }, + { header: 'Devices', key: 'deviceCount', width: 1.5 }, + ]} + rows={detectedRows} + /> +
    +
    + )} + + {/* ENTRA APPLICATIONS */} + {sectionConfig.entraApplications && ( + +
    + + The following AI services are registered as applications in the tenant, including any + permissions users have consented to + {consentedApps.length > consentedRows.length + ? `, showing the top ${consentedRows.length} of ${consentedApps.length} entries` + : ''} + . The consent date shows when each service first gained a foothold in the environment. + +
    + +
    + riskColor(row.risk) }, + { header: 'Status', key: 'status', width: 2.5 }, + { header: 'Users (7d)', key: 'activeUsersLast7Days', width: 2 }, + { header: 'First Consented', key: 'firstConsented', width: 2.5 }, + ]} + rows={consentedRows.map((app) => ({ + ...app, + firstConsented: app.firstConsentedDateTime + ? new Date(app.firstConsentedDateTime).toLocaleDateString() + : 'Unknown', + }))} + /> +
    +
    + )} + + {/* CHAPTER SPLITTER */} + {sectionConfig.infographics && ( + + employees shares sensitive work data + {'\n'} + with AI tools without approval + + } + footerText={ + <> + Sanctioned alternatives keep{'\n'} + your data under contract + + } + /> + )} + + {/* RECOMMENDATIONS */} + {sectionConfig.recommendations && ( + +
    + + A structured response to shadow AI combines approval of useful tools with controls on + the rest. The following actions are recommended based on the findings in this report: + +
    + +
    + +
    + +
    + + The AI tool landscape changes quickly and new tools appear in tenants within days of + release. We recommend re-running this assessment monthly and reviewing newly detected + tools against your acceptable AI use policy. + +
    +
    + )} + + ) +} + +// Exported so the branding preview can render this report against sample data, and so tests can +// render it to a real PDF. +export const ShadowAIReportDocument = ({ tenantName, brandingSettings, variables, ...props }) => ( + + + +) + +const sectionOptions = [ + { + key: 'coverPage', + label: 'Cover Page', + description: 'Branded title page with tenant name and date', + }, + { + key: 'executiveSummary', + label: 'Executive Summary', + description: 'High-level overview, usage statistics and top tools', + }, + { + key: 'infographics', + label: 'Infographic Pages', + description: 'Statistical pages with visual elements between sections', + }, + { + key: 'background', + label: 'Understanding Shadow AI', + description: 'Explains shadow AI and its key risk areas', + }, + { + key: 'riskLevels', + label: 'Risk Levels & Distribution', + description: 'Risk methodology and distribution chart', + }, + { + key: 'sanctionedTools', + label: 'Sanctioned Tools', + description: 'Company approved AI tools and their footprint', + }, + { + key: 'detectedSoftware', + label: 'AI Software (Intune)', + description: 'AI applications found on managed devices', + }, + { + key: 'entraApplications', + label: 'AI Applications (Entra)', + description: 'AI services with consented permissions in Entra ID', + }, + { + key: 'recommendations', + label: 'Recommendations', + description: 'Action plan for managing shadow AI', + }, +] + +export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { + const brandingSettings = useBrandingSettings() + const variables = useReportVariables() + const [previewOpen, setPreviewOpen] = useState(false) + // Below md the 320px config rail would leave the preview about 70px wide, so it moves into + // a drawer and the preview takes the whole dialog. Same treatment as the executive report. + const [sectionsOpen, setSectionsOpen] = useState(false) + const [sectionConfig, setSectionConfig] = useState({ + coverPage: true, + executiveSummary: true, + infographics: true, + background: true, + riskLevels: true, + sanctionedTools: true, + detectedSoftware: true, + entraApplications: true, + recommendations: true, + }) + + const handleSectionToggle = (sectionKey) => { + setSectionConfig((prev) => { + const enabledSections = Object.values(prev).filter(Boolean).length + // Keep at least one section enabled + if (prev[sectionKey] && enabledSections === 1) { + return prev + } + return { ...prev, [sectionKey]: !prev[sectionKey] } + }) + } + + const fileName = `Shadow_AI_Report_${String(tenantName).replace(/[^a-zA-Z0-9]/g, '_')}_${ + new Date().toISOString().split('T')[0] + }.pdf` + + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Sections", so it takes the panel without the heading. + const sectionPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Sections + + )} + + Configure which sections to include in your Shadow AI report. Changes are reflected in + real-time. + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + ) + + const reportDocument = useMemo(() => { + if (!previewOpen) return null + return ( + + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewOpen, tenantName, data, brandingSettings, variables, JSON.stringify(sectionConfig)]) + + return ( + <> + + + + + + + setPreviewOpen(false)} + maxWidth="xl" + fullWidth + sx={{ + '& .MuiDialog-paper': { + // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows. + height: { xs: '100dvh', md: '95vh' }, + maxHeight: { xs: '100dvh', md: '95vh' }, + }, + }} + > + + + Shadow AI Report - {tenantName} + + + {/* The config rail's stand-in below md, in the title bar because the dialog is + full-screen there and this is the only chrome that stays put. */} + setSectionsOpen(true)} + size="small" + aria-label="Report sections" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + setPreviewOpen(false)} + size="small" + aria-label="Close preview" + > + + + + + + {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} + + {sectionPanel()} + + + {/* Right Panel - PDF Preview */} + + {reportDocument && ( + + {reportDocument} + + )} + + + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > + + + Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} + {sectionOptions.length} + + + + + + + + {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over + the dialog it is opened from. */} + setSectionsOpen(false)} + title="Report Sections" + size="sm" + contentPadding={0} + aboveModal + > + {sectionPanel({ showHeading: false })} + + + + ); +} \ No newline at end of file diff --git a/src/components/action-list-item.js b/src/components/action-list-item.jsx similarity index 100% rename from src/components/action-list-item.js rename to src/components/action-list-item.jsx diff --git a/src/components/action-list.js b/src/components/action-list.jsx similarity index 100% rename from src/components/action-list.js rename to src/components/action-list.jsx diff --git a/src/components/actions-menu.js b/src/components/actions-menu.js deleted file mode 100644 index 19f4ec9f2d66..000000000000 --- a/src/components/actions-menu.js +++ /dev/null @@ -1,75 +0,0 @@ -import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; -import PropTypes from "prop-types"; -import { Button, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material"; -import { usePopover } from "../hooks/use-popover"; -import { useActionsDispatch } from "../hooks/use-actions-dispatch"; - -export const ActionsMenu = (props) => { - const { actions = [], label = "Actions", data, queryKeys, ...other } = props; - const popover = usePopover(); - const { visibleActions, isDisabled, dispatch, dialog } = useActionsDispatch({ - actions, - data, - queryKeys, - }); - - return ( - <> - - - {visibleActions.map((action, index) => ( - { - dispatch(action); - popover.handleClose(); - }} - > - - {action.icon} - - {action.label} - - ))} - - {dialog} - - ); -}; - -ActionsMenu.propTypes = { - actions: PropTypes.array, - label: PropTypes.string, -}; diff --git a/src/components/actions-menu.jsx b/src/components/actions-menu.jsx new file mode 100644 index 000000000000..be62717a7111 --- /dev/null +++ b/src/components/actions-menu.jsx @@ -0,0 +1,77 @@ +import PropTypes from "prop-types"; +import { CippIcons } from "../utils/icon-registry"; +import { Button, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material"; +import { usePopover } from "../hooks/use-popover"; +import { useActionsDispatch } from "../hooks/use-actions-dispatch"; + +export const ActionsMenu = (props) => { + const { actions = [], label = "Actions", data, queryKeys, ...other } = props; + const popover = usePopover(); + const { visibleActions, isDisabled, dispatch, dialog } = useActionsDispatch({ + actions, + data, + queryKeys, + }); + + return ( + <> + + + {visibleActions.map((action, index) => ( + { + dispatch(action); + popover.handleClose(); + }} + > + + {action.icon} + + {action.label} + + ))} + + {dialog} + + ); +}; + +ActionsMenu.propTypes = { + actions: PropTypes.array, + label: PropTypes.string, +}; diff --git a/src/components/bulk-actions-menu.js b/src/components/bulk-actions-menu.js deleted file mode 100644 index 18f1ad5af1c0..000000000000 --- a/src/components/bulk-actions-menu.js +++ /dev/null @@ -1,91 +0,0 @@ -import PropTypes from 'prop-types' -import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon' -import { Button, Link, ListItemText, Menu, MenuItem, SvgIcon } from '@mui/material' -import { usePopover } from '../hooks/use-popover' -import { getIconByName } from '../utils/icon-registry' - -export const BulkActionsMenu = (props) => { - const { buttonName, sx, row, actions = [], ...other } = props - const popover = usePopover() - - return ( - <> - - - {actions.map((action, index) => { - const icon = getIconByName(action.icon, { sx: { mr: 1 } }) - - if (action.link) { - return ( - - {icon} - - - ) - } else { - return ( - { - if (action.onClick) { - action.onClick() - } - popover.handleClose() - }} - > - {icon} - - - ) - } - })} - - - ) -} - -BulkActionsMenu.propTypes = { - onArchive: PropTypes.func, - onDelete: PropTypes.func, - selectedCount: PropTypes.number, -} diff --git a/src/components/bulk-actions-menu.jsx b/src/components/bulk-actions-menu.jsx new file mode 100644 index 000000000000..646580a05e79 --- /dev/null +++ b/src/components/bulk-actions-menu.jsx @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types' +import { Button, Link, ListItemText, Menu, MenuItem, SvgIcon } from '@mui/material' +import { usePopover } from '../hooks/use-popover' +import { CippIcons, getIconByName } from '../utils/icon-registry' + +export const BulkActionsMenu = (props) => { + const { buttonName, sx, row, actions = [], ...other } = props + const popover = usePopover() + + return ( + <> + + + {actions.map((action, index) => { + const icon = getIconByName(action.icon, { sx: { mr: 1 } }) + + if (action.link) { + return ( + + {icon} + + + ) + } else { + return ( + { + if (action.onClick) { + action.onClick() + } + popover.handleClose() + }} + > + {icon} + + + ) + } + })} + + + ); +} + +BulkActionsMenu.propTypes = { + onArchive: PropTypes.func, + onDelete: PropTypes.func, + selectedCount: PropTypes.number, +} diff --git a/src/components/confirmation-dialog.js b/src/components/confirmation-dialog.js deleted file mode 100644 index 1ec98341e0a6..000000000000 --- a/src/components/confirmation-dialog.js +++ /dev/null @@ -1,122 +0,0 @@ -import PropTypes from 'prop-types'; -import ExclamationCircleIcon from '@heroicons/react/24/outline/ExclamationCircleIcon'; -import ExclamationTriangleIcon from '@heroicons/react/24/outline/ExclamationTriangleIcon'; -import { - Button, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Stack, - SvgIcon, - Typography -} from '@mui/material'; - -const iconMap = { - error: ( - - - - ), - warning: ( - - - - ), - info: ( - - - - ) -}; - -export const ConfirmationDialog = (props) => { - const { - message = '', - onCancel, - onConfirm, - open = false, - title, - variant = 'info', - confirmLoading = false, - ...other - } = props; - - const icon = iconMap[variant]; - - const handleDialogClose = (event, reason) => { - if (confirmLoading) { - return; - } - if (onCancel) { - onCancel(event); - } - }; - - return ( - - - - {icon} - - {title} - - - - - - {message} - - - - - - - - ); -}; - -ConfirmationDialog.propTypes = { - message: PropTypes.string, - onCancel: PropTypes.func, - onConfirm: PropTypes.func, - open: PropTypes.bool, - title: PropTypes.string, - variant: PropTypes.oneOf(['error', 'warning', 'info']), - confirmLoading: PropTypes.bool -}; diff --git a/src/components/confirmation-dialog.jsx b/src/components/confirmation-dialog.jsx new file mode 100644 index 000000000000..ff719e2f03f8 --- /dev/null +++ b/src/components/confirmation-dialog.jsx @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import { CippIcons } from '../utils/icon-registry'; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + SvgIcon, + Typography +} from '@mui/material'; + +const iconMap = { + error: ( + + + + ), + warning: ( + + + + ), + info: ( + + + + ) +}; + +export const ConfirmationDialog = (props) => { + const { + message = '', + onCancel, + onConfirm, + open = false, + title, + variant = 'info', + confirmLoading = false, + ...other + } = props; + + const icon = iconMap[variant]; + + const handleDialogClose = (event, reason) => { + if (confirmLoading) { + return; + } + if (onCancel) { + onCancel(event); + } + }; + + return ( + + + + {icon} + + {title} + + + + + + {message} + + + + + + + + ); +}; + +ConfirmationDialog.propTypes = { + message: PropTypes.string, + onCancel: PropTypes.func, + onConfirm: PropTypes.func, + open: PropTypes.bool, + title: PropTypes.string, + variant: PropTypes.oneOf(['error', 'warning', 'info']), + confirmLoading: PropTypes.bool +}; diff --git a/src/components/csvExportButton.js b/src/components/csvExportButton.js deleted file mode 100644 index 3b619defc197..000000000000 --- a/src/components/csvExportButton.js +++ /dev/null @@ -1,113 +0,0 @@ -import { BackupTableTwoTone } from '@mui/icons-material' -import { IconButton, Tooltip } from '@mui/material' -import { mkConfig, generateCsv, download } from 'export-to-csv' -import { getCippFormatting } from '../utils/get-cipp-formatting' -import { SKIP_RECURSION_KEYS } from '../utils/skip-recursion-keys' -const csvConfig = mkConfig({ - fieldSeparator: ',', - decimalSeparator: '.', - useKeysAsHeaders: true, -}) - -// Flatten nested objects so deeply nested properties export as dotted columns. -const flattenObject = (obj, parentKey = '') => { - const flattened = {} - Object.keys(obj).forEach((key) => { - const fullKey = parentKey ? `${parentKey}.${key}` : key - if ( - typeof obj[key] === 'object' && - obj[key] !== null && - !Array.isArray(obj[key]) && - !SKIP_RECURSION_KEYS.includes(key) - ) { - Object.assign(flattened, flattenObject(obj[key], fullKey)) - } else if (Array.isArray(obj[key]) && typeof obj[key][0] === 'string') { - flattened[fullKey] = obj[key] - } else if (Array.isArray(obj[key])) { - let testFormatting = getCippFormatting(obj[key], key, 'text', false, false) - if (typeof testFormatting === 'string' && !testFormatting.includes('[object Object]')) { - flattened[fullKey] = testFormatting - } else { - flattened[fullKey] = obj[key] - .map((item) => - typeof item === 'object' - ? JSON.stringify( - Object.fromEntries( - Object.entries(flattenObject(item)).map(([k, v]) => [ - k, - getCippFormatting(v, k, 'text', false), - ]) - ) - ) - : getCippFormatting(item, fullKey, 'text', false, false) - ) - .join(', ') - } - } else { - flattened[fullKey] = obj[key] - } - }) - return flattened -} - -// Shared helper so both toolbar buttons and bulk actions reuse identical CSV logic. -export const exportRowsToCsv = ({ - rows = [], - columns = [], - reportName = 'Export', - columnVisibility = {}, -}) => { - if (!rows.length || !columns.length) { - return - } - - const rowData = rows.map((row) => flattenObject(row.original ?? row)) - const columnKeys = columns.filter((c) => columnVisibility[c.id]).map((c) => c.id) - - const filterRowData = (row, allowedKeys) => { - const filteredRow = {} - allowedKeys.forEach((key) => { - filteredRow[key] = key in row ? row[key] : null - }) - return filteredRow - } - - const filteredData = rowData.map((row) => filterRowData(row, columnKeys)) - - // Apply standard CIPP formatting so CSV values match on-screen representations. - const formattedData = filteredData.map((row) => { - const formattedRow = {} - columnKeys.forEach((key) => { - const value = row[key] - if (typeof value === 'string') { - formattedRow[key] = value - return - } - - formattedRow[key] = getCippFormatting(value, key, 'text', false) - }) - return formattedRow - }) - - const csv = generateCsv(csvConfig)(formattedData) - csvConfig['filename'] = `${reportName}` - download(csvConfig)(csv) -} - -export const CSVExportButton = (props) => { - const { rows = [], columns = [], reportName, columnVisibility = {}, ...other } = props - - return ( - - - exportRowsToCsv({ rows, columns, reportName, columnVisibility })} - {...other} - > - - - - - ) -} diff --git a/src/components/csvExportButton.jsx b/src/components/csvExportButton.jsx new file mode 100644 index 000000000000..408e89f75eff --- /dev/null +++ b/src/components/csvExportButton.jsx @@ -0,0 +1,113 @@ +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' +const csvConfig = mkConfig({ + fieldSeparator: ',', + decimalSeparator: '.', + useKeysAsHeaders: true, +}) + +// Flatten nested objects so deeply nested properties export as dotted columns. +const flattenObject = (obj, parentKey = '') => { + const flattened = {} + Object.keys(obj).forEach((key) => { + const fullKey = parentKey ? `${parentKey}.${key}` : key + if ( + typeof obj[key] === 'object' && + obj[key] !== null && + !Array.isArray(obj[key]) && + !SKIP_RECURSION_KEYS.includes(key) + ) { + Object.assign(flattened, flattenObject(obj[key], fullKey)) + } else if (Array.isArray(obj[key]) && typeof obj[key][0] === 'string') { + flattened[fullKey] = obj[key] + } else if (Array.isArray(obj[key])) { + let testFormatting = getCippFormatting(obj[key], key, 'text', false, false) + if (typeof testFormatting === 'string' && !testFormatting.includes('[object Object]')) { + flattened[fullKey] = testFormatting + } else { + flattened[fullKey] = obj[key] + .map((item) => + typeof item === 'object' + ? JSON.stringify( + Object.fromEntries( + Object.entries(flattenObject(item)).map(([k, v]) => [ + k, + getCippFormatting(v, k, 'text', false), + ]) + ) + ) + : getCippFormatting(item, fullKey, 'text', false, false) + ) + .join(', ') + } + } else { + flattened[fullKey] = obj[key] + } + }) + return flattened +} + +// Shared helper so both toolbar buttons and bulk actions reuse identical CSV logic. +export const exportRowsToCsv = ({ + rows = [], + columns = [], + reportName = 'Export', + columnVisibility = {}, +}) => { + if (!rows.length || !columns.length) { + return + } + + const rowData = rows.map((row) => flattenObject(row.original ?? row)) + const columnKeys = columns.filter((c) => columnVisibility[c.id]).map((c) => c.id) + + const filterRowData = (row, allowedKeys) => { + const filteredRow = {} + allowedKeys.forEach((key) => { + filteredRow[key] = key in row ? row[key] : null + }) + return filteredRow + } + + const filteredData = rowData.map((row) => filterRowData(row, columnKeys)) + + // Apply standard CIPP formatting so CSV values match on-screen representations. + const formattedData = filteredData.map((row) => { + const formattedRow = {} + columnKeys.forEach((key) => { + const value = row[key] + if (typeof value === 'string') { + formattedRow[key] = value + return + } + + formattedRow[key] = getCippFormatting(value, key, 'text', false) + }) + return formattedRow + }) + + const csv = generateCsv(csvConfig)(formattedData) + csvConfig['filename'] = `${reportName}` + download(csvConfig)(csv) +} + +export const CSVExportButton = (props) => { + const { rows = [], columns = [], reportName, columnVisibility = {}, ...other } = props + + return ( + + + exportRowsToCsv({ rows, columns, reportName, columnVisibility })} + {...other} + > + + + + + ) +} diff --git a/src/components/file-dropzone.js b/src/components/file-dropzone.js deleted file mode 100644 index 166f57cce23f..000000000000 --- a/src/components/file-dropzone.js +++ /dev/null @@ -1,80 +0,0 @@ -import PropTypes from 'prop-types'; -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) => { - const { accept, caption, maxFiles, maxSize, minSize, onDrop, sx } = props; - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept, - maxFiles, - maxSize, - minSize, - onDrop - }); - - return ( - theme.palette.mode === 'dark' - ? 'neutral.800' - : 'neutral.200', - borderRadius: 1, - borderStyle: 'dashed', - borderWidth: 1, - cursor: 'pointer', - display: 'flex', - flexDirection: 'column', - flexWrap: 'wrap', - justifyContent: 'center', - outline: 'none', - width: '100%', - py: 2, - ...(isDragActive && { - borderColor: 'primary.main', - backgroundColor: 'action.hover' - }), - '&:hover': { - borderColor: 'primary.main', - backgroundColor: 'action.hover' - }, - ...sx - }} - {...getRootProps()}> - - - - - - {caption && ( - - {caption} - - )} - - - ); -}; - -FileDropzone.propTypes = { - accept: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), - caption: PropTypes.string, - maxFiles: PropTypes.number, - maxSize: PropTypes.number, - minSize: PropTypes.number, - onDrop: PropTypes.func, - sx: PropTypes.object -}; diff --git a/src/components/file-dropzone.jsx b/src/components/file-dropzone.jsx new file mode 100644 index 000000000000..5ae36cecd275 --- /dev/null +++ b/src/components/file-dropzone.jsx @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import { CippIcons } from '../utils/icon-registry'; +import { useDropzone } from 'react-dropzone'; +import { Avatar, Box, SvgIcon, Typography } from '@mui/material'; + +export const FileDropzone = (props) => { + const { accept, caption, maxFiles, maxSize, minSize, onDrop, sx } = props; + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept, + maxFiles, + maxSize, + minSize, + onDrop + }); + + return ( + theme.palette.mode === 'dark' + ? 'neutral.800' + : 'neutral.200', + borderRadius: 1, + borderStyle: 'dashed', + borderWidth: 1, + cursor: 'pointer', + display: 'flex', + flexDirection: 'column', + flexWrap: 'wrap', + justifyContent: 'center', + outline: 'none', + width: '100%', + py: 2, + ...(isDragActive && { + borderColor: 'primary.main', + backgroundColor: 'action.hover' + }), + '&:hover': { + borderColor: 'primary.main', + backgroundColor: 'action.hover' + }, + ...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 && ( + + {caption} + + )} + + + ); +}; + +FileDropzone.propTypes = { + accept: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), + caption: PropTypes.string, + maxFiles: PropTypes.number, + maxSize: PropTypes.number, + minSize: PropTypes.number, + onDrop: PropTypes.func, + sx: PropTypes.object +}; diff --git a/src/components/filter-dialog-item.js b/src/components/filter-dialog-item.js deleted file mode 100644 index 82c1ef3c71b1..000000000000 --- a/src/components/filter-dialog-item.js +++ /dev/null @@ -1,148 +0,0 @@ -import { useMemo } from 'react'; -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'; - -export const FilterDialogItem = (props) => { - const { - disableAdd = false, - displayAdd = false, - filter, - index = 0, - onAdd, - onOperatorChange, - onPropertyChange, - onRemoveFilter, - onValueChange, - operators = [], - properties = [] - } = props; - - const property = useMemo(() => { - return properties.find((property) => property.name === filter.property); - }, [filter, properties]); - - const operator = useMemo(() => { - return operators.find((operator) => operator.name === filter.operator); - }, [filter, operators]); - - const operatorOptions = useMemo(() => { - return (property?.operators || []) - .map((name) => operators.find((operator) => operator.name === name)) - .filter((operator) => !!operator); - }, [property, operators]); - - return ( - - - Where - - - - - - - {operator?.field === 'date' && ( - { - if (date) { - onValueChange?.(index, date); - } - }} - renderInput={(inputProps) => ( - - )} - value={filter.value || null} - /> - )} - {operator?.field === 'string' && ( - onValueChange?.(index, event.target.value)} - value={filter.value || ''} - /> - )} - {operator?.field === 'number' && ( - onValueChange?.(index, event.target.value)} - value={filter.value || ''} - /> - )} - - - {displayAdd && ( - - )} - - - - ); -}; - -FilterDialogItem.propTypes = { - disableAdd: PropTypes.bool, - displayAdd: PropTypes.bool, - filter: PropTypes.object.isRequired, - index: PropTypes.number, - onAdd: PropTypes.func, - onOperatorChange: PropTypes.func, - onPropertyChange: PropTypes.func, - onRemoveFilter: PropTypes.func, - onValueChange: PropTypes.func, - operators: PropTypes.array, - properties: PropTypes.array -}; diff --git a/src/components/filter-dialog-item.jsx b/src/components/filter-dialog-item.jsx new file mode 100644 index 000000000000..cc5a50caf551 --- /dev/null +++ b/src/components/filter-dialog-item.jsx @@ -0,0 +1,151 @@ +import { useMemo } from 'react'; +import { CippIcons } from '../utils/icon-registry'; +import PropTypes from 'prop-types'; +import { Button, FilledInput, MenuItem, Select, Stack, TextField, Typography } from '@mui/material'; +import { DatePicker } from '@mui/x-date-pickers'; + +export const FilterDialogItem = (props) => { + const { + disableAdd = false, + displayAdd = false, + filter, + index = 0, + onAdd, + onOperatorChange, + onPropertyChange, + onRemoveFilter, + onValueChange, + operators = [], + properties = [] + } = props; + + const property = useMemo(() => { + return properties.find((property) => property.name === filter.property); + }, [filter, properties]); + + const operator = useMemo(() => { + return operators.find((operator) => operator.name === filter.operator); + }, [filter, operators]); + + const operatorOptions = useMemo(() => { + return (property?.operators || []) + .map((name) => operators.find((operator) => operator.name === name)) + .filter((operator) => !!operator); + }, [property, operators]); + + return ( + + + Where + + + + + + + {operator?.field === 'date' && ( + { + if (date) { + onValueChange?.(index, date); + } + }} + renderInput={(inputProps) => ( + + )} + value={filter.value || null} + /> + )} + {operator?.field === 'string' && ( + onValueChange?.(index, event.target.value)} + value={filter.value || ''} + /> + )} + {operator?.field === 'number' && ( + onValueChange?.(index, event.target.value)} + value={filter.value || ''} + /> + )} + + + {displayAdd && ( + + )} + + + + ); +}; + +FilterDialogItem.propTypes = { + disableAdd: PropTypes.bool, + displayAdd: PropTypes.bool, + filter: PropTypes.object.isRequired, + index: PropTypes.number, + onAdd: PropTypes.func, + onOperatorChange: PropTypes.func, + onPropertyChange: PropTypes.func, + onRemoveFilter: PropTypes.func, + onValueChange: PropTypes.func, + operators: PropTypes.array, + properties: PropTypes.array +}; diff --git a/src/components/filter-dialog.js b/src/components/filter-dialog.js deleted file mode 100644 index 8510767fcf23..000000000000 --- a/src/components/filter-dialog.js +++ /dev/null @@ -1,117 +0,0 @@ -import { useCallback } from "react"; -import PropTypes from "prop-types"; -import XMarkIcon from "@heroicons/react/24/outline/XMarkIcon"; -import { - Button, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Divider, - IconButton, - Stack, - SvgIcon, - Typography, -} from "@mui/material"; -import { useFilters } from "../hooks/use-filters"; -import { FilterDialogItem } from "./filter-dialog-item"; - -export const FilterDialog = (props) => { - const { - filters = [], - onApply, - onClear, - onClose, - open = false, - operators = [], - properties = [], - } = props; - const filtersController = useFilters(operators, properties, filters); - - const handleApply = useCallback(() => { - if (filtersController.valid) { - onApply?.(filtersController.filters); - } - }, [filtersController.filters, filtersController.valid, onApply]); - - const displayClear = filters.length > 0; - - return ( - - - Filter - - - - - - - - - - - } - > - {filtersController.filters.map((filter, index) => { - const displayAdd = filtersController.filters.length === index + 1; - - return ( - - ); - })} - - - - {displayClear && ( - - )} - - - - ); -}; - -FilterDialog.propTypes = { - onApply: PropTypes.func, - onClear: PropTypes.func, - onClose: PropTypes.func, - open: PropTypes.bool, - operators: PropTypes.array, - properties: PropTypes.array, -}; diff --git a/src/components/filter-dialog.jsx b/src/components/filter-dialog.jsx new file mode 100644 index 000000000000..a448b215ee0e --- /dev/null +++ b/src/components/filter-dialog.jsx @@ -0,0 +1,119 @@ +import { useCallback } from "react"; +import { CippIcons } from "../utils/icon-registry"; +import PropTypes from "prop-types"; +import { + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import { useFilters } from "../hooks/use-filters"; +import { FilterDialogItem } from "./filter-dialog-item"; + +export const FilterDialog = (props) => { + const { + filters = [], + onApply, + onClear, + onClose, + open = false, + operators = [], + properties = [], + } = props; + const filtersController = useFilters(operators, properties, filters); + + const handleApply = useCallback(() => { + if (filtersController.valid) { + onApply?.(filtersController.filters); + } + }, [filtersController.filters, filtersController.valid, onApply]); + + const displayClear = filters.length > 0; + + return ( + + + Filter + + + + + + + + + + + } + > + {filtersController.filters.map((filter, index) => { + const displayAdd = filtersController.filters.length === index + 1; + + return ( + + ); + })} + + + + {displayClear && ( + + )} + + + + ); +}; + +FilterDialog.propTypes = { + onApply: PropTypes.func, + onClear: PropTypes.func, + onClose: PropTypes.func, + open: PropTypes.bool, + operators: PropTypes.array, + properties: PropTypes.array, +}; diff --git a/src/components/images-dialog.js b/src/components/images-dialog.js deleted file mode 100644 index d17c6d1cad8f..000000000000 --- a/src/components/images-dialog.js +++ /dev/null @@ -1,184 +0,0 @@ -import { useState } from 'react'; -import PropTypes from 'prop-types'; -import TrashIcon from '@heroicons/react/24/outline/TrashIcon'; -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - IconButton, - Stack, - SvgIcon, - Typography -} from '@mui/material'; -import { alpha } from '@mui/material/styles'; -import { FileDropzone } from './file-dropzone'; - -export const ImagesDialog = (props) => { - const { onClose, onSelect, open = false, ...other } = props; - const [uploaded, setUploaded] = useState([]); - const [selected, setSelected] = useState([]); - - const handleDrop = (files) => { - - // Here you should upload the images and get the URL back. - // We simulate that by creating Object URLs from the file data. - - const images = files.map((file) => URL.createObjectURL(file)); - - setUploaded(prevState => ([ - ...prevState, - ...images - ])); - - setSelected(prevState => ([ - ...prevState, - ...images - ])); - }; - - const handleSelectOne = (image) => { - if (selected.includes(image)) { - setSelected((prevState) => { - return prevState.filter((_image) => _image !== image); - }); - } else { - setSelected((prevSelectedImages) => [...prevSelectedImages, image]); - } - }; - - const handleDeleteOne = (image) => { - setUploaded((prevState) => { - return prevState.filter((_image) => _image !== image); - }); - }; - - const handleSelect = () => { - onSelect?.(selected); - setSelected([]); - setUploaded([]); - }; - - return ( - - - - Upload Images - - - You can only choose images - - - - - - {uploaded.map((image) => { - const isSelected = selected.includes(image); - - return ( - handleSelectOne(image)} - sx={{ - backgroundImage: `url(${image})`, - backgroundPosition: 'center', - backgroundSize: 'cover', - borderRadius: 1, - cursor: 'pointer', - height: 122, - flexGrow: 0, - flexShrink: 0, - overflow: 'hidden', - position: 'relative', - width: 122, - ...(isSelected && { - boxShadow: (theme) => `0px 0px 0px 2px ${theme.palette.primary.main}` - }), - '&:hover': { - '& > div': { - opacity: 1 - } - } - }} - > - alpha(theme.palette.neutral[900], 0.8), - display: 'flex', - height: '100%', - justifyContent: 'center', - left: 0, - opacity: 0, - position: 'absolute', - top: 0, - width: '100%' - }} - > - { - event.stopPropagation(); - handleDeleteOne(image); - }} - > - - - - - - - ); - })} - - - - - - - - ); -}; - -ImagesDialog.propTypes = { - onClose: PropTypes.func, - onSelect: PropTypes.func, - open: PropTypes.bool, - selected: PropTypes.arrayOf(PropTypes.string.isRequired) -}; diff --git a/src/components/images-dialog.jsx b/src/components/images-dialog.jsx new file mode 100644 index 000000000000..cfa77b03a300 --- /dev/null +++ b/src/components/images-dialog.jsx @@ -0,0 +1,190 @@ +import { useState } from 'react'; +import { CippIcons } from '../utils/icon-registry'; +import PropTypes from 'prop-types'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + SvgIcon, + Typography +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { FileDropzone } from './file-dropzone'; + +export const ImagesDialog = (props) => { + const { onClose, onSelect, open = false, ...other } = props; + const [uploaded, setUploaded] = useState([]); + const [selected, setSelected] = useState([]); + + const handleDrop = (files) => { + + // Here you should upload the images and get the URL back. + // We simulate that by creating Object URLs from the file data. + + const images = files.map((file) => URL.createObjectURL(file)); + + setUploaded(prevState => ([ + ...prevState, + ...images + ])); + + setSelected(prevState => ([ + ...prevState, + ...images + ])); + }; + + const handleSelectOne = (image) => { + if (selected.includes(image)) { + setSelected((prevState) => { + return prevState.filter((_image) => _image !== image); + }); + } else { + setSelected((prevSelectedImages) => [...prevSelectedImages, image]); + } + }; + + const handleDeleteOne = (image) => { + setUploaded((prevState) => { + return prevState.filter((_image) => _image !== image); + }); + }; + + const handleSelect = () => { + onSelect?.(selected); + setSelected([]); + setUploaded([]); + }; + + return ( + + + + Upload Images + + + You can only choose images + + + + + + {uploaded.map((image) => { + const isSelected = selected.includes(image); + + return ( + handleSelectOne(image)} + sx={{ + backgroundImage: `url(${image})`, + backgroundPosition: 'center', + backgroundSize: 'cover', + borderRadius: 1, + cursor: 'pointer', + height: 122, + flexGrow: 0, + flexShrink: 0, + overflow: 'hidden', + position: 'relative', + width: 122, + ...(isSelected && { + boxShadow: (theme) => `0px 0px 0px 2px ${theme.palette.primary.main}` + }), + '&:hover': { + '& > div': { + opacity: 1 + } + } + }} + > + alpha(theme.palette.neutral[900], 0.8), + display: 'flex', + height: '100%', + justifyContent: 'center', + left: 0, + opacity: 0, + position: 'absolute', + top: 0, + width: '100%' + }} + > + { + event.stopPropagation(); + handleDeleteOne(image); + }} + > + + + + + + + ); + })} + + + + + + + + ); +}; + +ImagesDialog.propTypes = { + onClose: PropTypes.func, + onSelect: PropTypes.func, + open: PropTypes.bool, + selected: PropTypes.arrayOf(PropTypes.string.isRequired) +}; diff --git a/src/components/logo.js b/src/components/logo.jsx similarity index 100% rename from src/components/logo.js rename to src/components/logo.jsx diff --git a/src/components/pagination.js b/src/components/pagination.js deleted file mode 100644 index 1108ddc11e0a..000000000000 --- a/src/components/pagination.js +++ /dev/null @@ -1,92 +0,0 @@ -import { useCallback } from 'react'; -import PropTypes from 'prop-types'; -import ChevronLeftIcon from '@heroicons/react/24/outline/ChevronLeftIcon'; -import ChevronRightIcon from '@heroicons/react/24/outline/ChevronRightIcon'; -import { Box, IconButton, Stack, SvgIcon, Typography } from '@mui/material'; -import { styled } from '@mui/material/styles'; - -const PaginationRoot = styled('div')((({ theme }) => ({ - alignItems: 'center', - display: 'flex', - padding: theme.spacing(2) -}))); - -export const Pagination = (props) => { - const { disabled, onPageChange, page = 0, rowsCount = 0, rowsPerPage = 0, ...other } = props; - const pagesCount = Math.ceil(rowsCount / rowsPerPage) || 1; - const isFirstPage = page === 0; - const isLastPage = (page + 1) === pagesCount; - - const handlePreviousPage = useCallback(() => { - onPageChange?.(page - 1); - }, [page, onPageChange]); - - const handleNextPage = useCallback(() => { - onPageChange?.(page + 1); - }, [page, onPageChange]); - - return ( - - - - Page - {' '} - - {page + 1} - - of - {' '} - - {pagesCount} - - - - - - - - - - - - - - - - - ); -}; - -Pagination.propTypes = { - disabled: PropTypes.bool, - onPageChange: PropTypes.func, - page: PropTypes.number, - rowsCount: PropTypes.number, - rowsPerPage: PropTypes.number -}; diff --git a/src/components/pagination.jsx b/src/components/pagination.jsx new file mode 100644 index 000000000000..2bf44482eca3 --- /dev/null +++ b/src/components/pagination.jsx @@ -0,0 +1,92 @@ +import { useCallback } from 'react'; +import { CippIcons } from '../utils/icon-registry'; +import PropTypes from 'prop-types'; +import { Box, IconButton, Stack, SvgIcon, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +const PaginationRoot = styled('div')((({ theme }) => ({ + alignItems: 'center', + display: 'flex', + padding: theme.spacing(2) +}))); + +export const Pagination = (props) => { + const { disabled, onPageChange, page = 0, rowsCount = 0, rowsPerPage = 0, ...other } = props; + const pagesCount = Math.ceil(rowsCount / rowsPerPage) || 1; + const isFirstPage = page === 0; + const isLastPage = (page + 1) === pagesCount; + + const handlePreviousPage = useCallback(() => { + onPageChange?.(page - 1); + }, [page, onPageChange]); + + const handleNextPage = useCallback(() => { + onPageChange?.(page + 1); + }, [page, onPageChange]); + + return ( + + + + Page + {' '} + + {page + 1} + + of + {' '} + + {pagesCount} + + + + + + + + + + + + + + + + + ); +}; + +Pagination.propTypes = { + disabled: PropTypes.bool, + onPageChange: PropTypes.func, + page: PropTypes.number, + rowsCount: PropTypes.number, + rowsPerPage: PropTypes.number +}; diff --git a/src/components/pdfExportButton.js b/src/components/pdfExportButton.js deleted file mode 100644 index 8623af5f109a..000000000000 --- a/src/components/pdfExportButton.js +++ /dev/null @@ -1,184 +0,0 @@ -import { IconButton, Tooltip } from '@mui/material' -import { PictureAsPdf } from '@mui/icons-material' -import { getCippFormatting } from '../utils/get-cipp-formatting' -import { SKIP_RECURSION_KEYS } from '../utils/skip-recursion-keys' -import { useBrandingSettings } from './CippPdf/useBrandingSettings' - -// Flatten nested objects so deeply nested properties export properly. -// This function only restructures data without formatting - formatting happens later in one pass. -const flattenObject = (obj, parentKey = '') => { - const flattened = {} - Object.keys(obj).forEach((key) => { - const fullKey = parentKey ? `${parentKey}.${key}` : key - if ( - typeof obj[key] === 'object' && - obj[key] !== null && - !Array.isArray(obj[key]) && - !SKIP_RECURSION_KEYS.includes(key) - ) { - Object.assign(flattened, flattenObject(obj[key], fullKey)) - } else { - // Store the raw value - formatting will happen in a single pass later - flattened[fullKey] = obj[key] - } - }) - return flattened -} - -// Shared helper so the toolbar buttons and bulk export path share the same PDF logic. -export const exportRowsToPdf = async ({ - rows = [], - columns = [], - reportName = 'Export', - columnVisibility = {}, - brandingSettings = {}, -}) => { - if (!rows.length || !columns.length) { - return - } - - // Lazy-load jsPDF (+autotable) so ~1MB of PDF code stays out of the common bundle until an export. - const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([ - import('jspdf'), - import('jspdf-autotable'), - ]) - - const unit = 'pt' - const size = 'A3' - const orientation = 'landscape' - const doc = new jsPDF(orientation, unit, size) - const tableData = rows.map((row) => flattenObject(row.original ?? row)) - - const exportColumns = columns - .filter((c) => columnVisibility[c.id]) - .map((c) => ({ header: c.header, dataKey: c.id })) - - // Use the existing formatting helper so PDF output mirrors table formatting. - const formattedData = tableData.map((row) => { - const formattedRow = {} - exportColumns.forEach((col) => { - const key = col.dataKey - formattedRow[key] = getCippFormatting(key in row ? row[key] : null, key, 'text', false) - }) - return formattedRow - }) - - let logoHeight = 0 - if (brandingSettings?.logo) { - try { - const logoSize = 60 - const logoX = 40 - const logoY = 30 - doc.addImage(brandingSettings.logo, 'PNG', logoX, logoY, logoSize, logoSize) - logoHeight = logoSize + 20 - } catch (error) { - console.warn('Failed to add logo to PDF:', error) - } - } - - const pageWidth = doc.internal.pageSize.getWidth() - const margin = 40 - const availableWidth = pageWidth - 2 * margin - const columnCount = exportColumns.length - - // Estimate column widths from content to keep tables readable regardless of dataset. - const columnWidths = exportColumns.map((col) => { - const headerLength = col.header.length - const maxContentLength = Math.max( - ...formattedData.map((row) => String(row[col.dataKey] || '').length) - ) - const estimatedWidth = Math.max(headerLength, maxContentLength) * 6 - return Math.min(estimatedWidth, (availableWidth / columnCount) * 1.5) - }) - - const totalEstimatedWidth = columnWidths.reduce((sum, width) => sum + width, 0) - const normalizedWidths = columnWidths.map( - (width) => (width / totalEstimatedWidth) * availableWidth - ) - - // Honor tenant branding colors when present so exports stay on-brand. - const getHeaderColor = () => { - if (brandingSettings?.colour) { - const hex = brandingSettings.colour.replace('#', '') - const r = parseInt(hex.substr(0, 2), 16) - const g = parseInt(hex.substr(2, 2), 16) - const b = parseInt(hex.substr(4, 2), 16) - return [r, g, b] - } - return [247, 127, 0] - } - - const content = { - startY: 100 + logoHeight, - head: [exportColumns.map((col) => col.header)], - body: formattedData.map((row) => exportColumns.map((col) => String(row[col.dataKey] || ''))), - theme: 'striped', - headStyles: { - fillColor: getHeaderColor(), - textColor: [255, 255, 255], - fontStyle: 'bold', - halign: 'center', - valign: 'middle', - fontSize: 10, - cellPadding: 8, - }, - bodyStyles: { - fontSize: 9, - cellPadding: 6, - valign: 'top', - overflow: 'linebreak', - cellWidth: 'wrap', - }, - columnStyles: exportColumns.reduce((styles, col, index) => { - styles[index] = { - cellWidth: normalizedWidths[index], - halign: 'left', - valign: 'top', - } - return styles - }, {}), - margin: { - top: margin, - right: margin, - bottom: margin, - left: margin, - }, - tableWidth: 'auto', - styles: { - overflow: 'linebreak', - cellWidth: 'wrap', - fontSize: 9, - cellPadding: 6, - }, - } - autoTable(doc, content) - - doc.save(`${reportName}.pdf`) -} - -export const PDFExportButton = (props) => { - const { rows = [], columns = [], reportName, columnVisibility = {}, ...other } = props - const brandingSettings = useBrandingSettings() - - return ( - - - - exportRowsToPdf({ - rows, - columns, - reportName, - columnVisibility, - brandingSettings, - }) - } - {...other} - > - - - - - ) -} diff --git a/src/components/pdfExportButton.jsx b/src/components/pdfExportButton.jsx new file mode 100644 index 000000000000..cb1aae15e56d --- /dev/null +++ b/src/components/pdfExportButton.jsx @@ -0,0 +1,220 @@ +import { IconButton, Tooltip } from '@mui/material' +import { CippIcons } from '../utils/icon-registry' +import { useQueryClient } from '@tanstack/react-query' +import { getCippFormatting } from '../utils/get-cipp-formatting' +import { SKIP_RECURSION_KEYS } from '../utils/skip-recursion-keys' +import { fetchBrandingSettings } from './CippPdf/useBrandingSettings' + +// Match branding preview maxWidth and sit close to report headerLogo height (30pt). +const MAX_LOGO_WIDTH = 140 +const MAX_LOGO_HEIGHT = 36 +const LOGO_PADDING = 12 + +const JSPDF_FORMAT_BY_MIME = { + 'image/png': 'PNG', + 'image/jpeg': 'JPEG', + 'image/jpg': 'JPEG', + 'image/webp': 'WEBP', +} + +/** + * jsPDF format from a data URL. SVG and anything else return null so the export + * continues without a logo rather than forcing a PNG decode. + */ +export const detectJsPdfImageFormat = (dataUrl) => { + if (typeof dataUrl !== 'string') return null + const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);/i) + if (!match) return null + return JSPDF_FORMAT_BY_MIME[match[1].toLowerCase()] ?? null +} + +/** Scale intrinsic size into a contain box without upscaling. */ +export const fitLogoDimensions = (natW, natH, { maxWidth = MAX_LOGO_WIDTH, maxHeight = MAX_LOGO_HEIGHT } = {}) => { + if (!natW || !natH || natW <= 0 || natH <= 0) return null + const scale = Math.min(maxWidth / natW, maxHeight / natH, 1) + return { width: natW * scale, height: natH * scale } +} + +// Flatten nested objects so deeply nested properties export properly. +// This function only restructures data without formatting - formatting happens later in one pass. +const flattenObject = (obj, parentKey = '') => { + const flattened = {} + Object.keys(obj).forEach((key) => { + const fullKey = parentKey ? `${parentKey}.${key}` : key + if ( + typeof obj[key] === 'object' && + obj[key] !== null && + !Array.isArray(obj[key]) && + !SKIP_RECURSION_KEYS.includes(key) + ) { + Object.assign(flattened, flattenObject(obj[key], fullKey)) + } else { + // Store the raw value - formatting will happen in a single pass later + flattened[fullKey] = obj[key] + } + }) + return flattened +} + +// Shared helper so the toolbar buttons and bulk export path share the same PDF logic. +export const exportRowsToPdf = async ({ + rows = [], + columns = [], + reportName = 'Export', + columnVisibility = {}, + brandingSettings = {}, +}) => { + if (!rows.length || !columns.length) { + return + } + + // Lazy-load jsPDF (+autotable) so ~1MB of PDF code stays out of the common bundle until an export. + const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([ + import('jspdf'), + import('jspdf-autotable'), + ]) + + const unit = 'pt' + const size = 'A3' + const orientation = 'landscape' + const doc = new jsPDF(orientation, unit, size) + const tableData = rows.map((row) => flattenObject(row.original ?? row)) + + const exportColumns = columns + .filter((c) => columnVisibility[c.id]) + .map((c) => ({ header: c.header, dataKey: c.id })) + + // Use the existing formatting helper so PDF output mirrors table formatting. + const formattedData = tableData.map((row) => { + const formattedRow = {} + exportColumns.forEach((col) => { + const key = col.dataKey + formattedRow[key] = getCippFormatting(key in row ? row[key] : null, key, 'text', false) + }) + return formattedRow + }) + + let logoHeight = 0 + const logo = brandingSettings?.logo + const logoFormat = detectJsPdfImageFormat(logo) + if (logo && logoFormat) { + try { + const { width: natW, height: natH } = doc.getImageProperties(logo) + const fitted = fitLogoDimensions(natW, natH) + if (fitted) { + const logoX = 40 + const logoY = 30 + doc.addImage(logo, logoFormat, logoX, logoY, fitted.width, fitted.height) + logoHeight = fitted.height + LOGO_PADDING + } + } catch (error) { + console.warn('Failed to add logo to PDF:', error) + } + } + + const pageWidth = doc.internal.pageSize.getWidth() + const margin = 40 + const availableWidth = pageWidth - 2 * margin + const columnCount = exportColumns.length + + // Estimate column widths from content to keep tables readable regardless of dataset. + const columnWidths = exportColumns.map((col) => { + const headerLength = col.header.length + const maxContentLength = Math.max( + ...formattedData.map((row) => String(row[col.dataKey] || '').length) + ) + const estimatedWidth = Math.max(headerLength, maxContentLength) * 6 + return Math.min(estimatedWidth, (availableWidth / columnCount) * 1.5) + }) + + const totalEstimatedWidth = columnWidths.reduce((sum, width) => sum + width, 0) + const normalizedWidths = columnWidths.map( + (width) => (width / totalEstimatedWidth) * availableWidth + ) + + // Honor tenant branding colors when present so exports stay on-brand. + const getHeaderColor = () => { + if (brandingSettings?.colour) { + const hex = brandingSettings.colour.replace('#', '') + const r = parseInt(hex.substr(0, 2), 16) + const g = parseInt(hex.substr(2, 2), 16) + const b = parseInt(hex.substr(4, 2), 16) + return [r, g, b] + } + return [247, 127, 0] + } + + const content = { + startY: 100 + logoHeight, + head: [exportColumns.map((col) => col.header)], + body: formattedData.map((row) => exportColumns.map((col) => String(row[col.dataKey] || ''))), + theme: 'striped', + headStyles: { + fillColor: getHeaderColor(), + textColor: [255, 255, 255], + fontStyle: 'bold', + halign: 'center', + valign: 'middle', + fontSize: 10, + cellPadding: 8, + }, + bodyStyles: { + fontSize: 9, + cellPadding: 6, + valign: 'top', + overflow: 'linebreak', + cellWidth: 'wrap', + }, + columnStyles: exportColumns.reduce((styles, col, index) => { + styles[index] = { + cellWidth: normalizedWidths[index], + halign: 'left', + valign: 'top', + } + return styles + }, {}), + margin: { + top: margin, + right: margin, + bottom: margin, + left: margin, + }, + tableWidth: 'auto', + styles: { + overflow: 'linebreak', + cellWidth: 'wrap', + fontSize: 9, + cellPadding: 6, + }, + } + autoTable(doc, content) + + doc.save(`${reportName}.pdf`) +} + +export const PDFExportButton = (props) => { + const { rows = [], columns = [], reportName, columnVisibility = {}, ...other } = props + const queryClient = useQueryClient() + + return ( + + + + exportRowsToPdf({ + rows, + columns, + reportName, + columnVisibility, + brandingSettings: await fetchBrandingSettings(queryClient), + }) + } + {...other} + > + + + + + ) +} diff --git a/src/components/property-list-item.js b/src/components/property-list-item.js deleted file mode 100644 index 4249e975cef0..000000000000 --- a/src/components/property-list-item.js +++ /dev/null @@ -1,64 +0,0 @@ -import { Box, Button, ListItem, ListItemText, Typography } from "@mui/material"; -import { useState } from "react"; -import { CippCopyToClipBoard } from "./CippComponents/CippCopyToClipboard"; - -export const PropertyListItem = (props) => { - const { - align = "vertical", - children, - component, - label, - value = "", - type, - copyItems, - sx = { - px: 3, - py: 1.5, - }, - ...other - } = props; - const [showPassword, setShowPassword] = useState(false); - return ( - - - {label} -
    - } - secondary={ - - {children || ( - - {type !== "password" && - (value === true ? "Yes" : value === false || value === null ? "No" : value)} - {type === "password" && ( - <> - {showPassword ? ( - value - ) : ( - - )} - - )} - {copyItems && } - - )} - - } - sx={{ - alignItems: "flex-start", - flexDirection: align === "vertical" ? "column" : "row", - my: 0, - width: "100%", - }} - /> - - ); -}; diff --git a/src/components/property-list-item.jsx b/src/components/property-list-item.jsx new file mode 100644 index 000000000000..94d3aba9e011 --- /dev/null +++ b/src/components/property-list-item.jsx @@ -0,0 +1,66 @@ +import { Box, Button, ListItem, ListItemText, Typography } from "@mui/material"; +import { useState } from "react"; +import { CippCopyToClipBoard } from "./CippComponents/CippCopyToClipboard"; + +export const PropertyListItem = (props) => { + const { + align = "vertical", + children, + component, + label, + value = "", + type, + copyItems, + sx = { + px: 3, + py: 1.5, + }, + ...other + } = props; + const [showPassword, setShowPassword] = useState(false); + return ( + + + {label} +
    + } + secondary={ + + {children || ( + + {type !== "password" && + (value === true ? "Yes" : value === false || value === null ? "No" : value)} + {type === "password" && ( + <> + {showPassword ? ( + value + ) : ( + + )} + + )} + {copyItems && } + + )} + + } + sx={{ + alignItems: "flex-start", + flexDirection: align === "vertical" ? "column" : "row", + my: 0, + width: "100%", + }} + /> + + ); +}; diff --git a/src/components/property-list.js b/src/components/property-list.jsx similarity index 100% rename from src/components/property-list.js rename to src/components/property-list.jsx diff --git a/src/components/query-field.js b/src/components/query-field.js deleted file mode 100644 index dfeb6f37e1a4..000000000000 --- a/src/components/query-field.js +++ /dev/null @@ -1,90 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; -import MagnifyingGlassIcon from '@heroicons/react/24/outline/MagnifyingGlassIcon'; -import { InputBase, SvgIcon } from '@mui/material'; -import { styled } from '@mui/material/styles'; - -const QueryFieldRoot = styled('div')((({ theme }) => ({ - alignItems: 'center', - backgroundColor: 'background.paper', - border: `1px solid ${theme.palette.divider}`, - borderRadius: theme.shape.borderRadius, - display: 'flex', - height: 42, - padding: '0 16px' -}))); - -export const QueryField = (props) => { - const { disabled, onChange, placeholder, value: initialValue = '', ...other } = props; - const [autoFocus, setAutoFocus] = useState(false); - const inputRef = useRef(null); - const [value, setValue] = useState(''); - - useEffect(() => { - setValue(initialValue); - }, [initialValue]); - - useEffect(() => { - if (!disabled && autoFocus && inputRef?.current) { - inputRef.current.focus(); - } - }, - [disabled]); - - const handleChange = useCallback((event) => { - setValue(event.target.value); - }, []); - - const handleKeyup = useCallback((event) => { - if (event.code === 'Enter') { - onChange?.(value); - } - }, [value, onChange]); - - const handleFocus = useCallback(() => { - setAutoFocus(true); - }, []); - - const handleBlur = useCallback((event) => { - /* - There is a situation where an input goes from not disabled to disabled and DOM emits a blur - event, with event as undefined. This means, that sometimes we'll receive an React Synthetic - event and sometimes undefined because when DOM triggers the event, React is unaware of it, - or it simply does not emit the event. To bypass this behaviour, we store a local variable - that acts as autofocus. - */ - - if (event) { - setAutoFocus(false); - } - }, []); - - return ( - - - - - - - ); -}; - -QueryField.propTypes = { - disabled: PropTypes.bool, - onChange: PropTypes.func, - placeholder: PropTypes.string, - value: PropTypes.string -}; diff --git a/src/components/query-field.jsx b/src/components/query-field.jsx new file mode 100644 index 000000000000..6e8b93bd8733 --- /dev/null +++ b/src/components/query-field.jsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CippIcons } from '../utils/icon-registry'; +import PropTypes from 'prop-types'; +import { InputBase, SvgIcon } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +const QueryFieldRoot = styled('div')((({ theme }) => ({ + alignItems: 'center', + backgroundColor: 'background.paper', + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + display: 'flex', + height: 42, + padding: '0 16px' +}))); + +export const QueryField = (props) => { + const { disabled, onChange, placeholder, value: initialValue = '', ...other } = props; + const [autoFocus, setAutoFocus] = useState(false); + const inputRef = useRef(null); + const [value, setValue] = useState(''); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + if (!disabled && autoFocus && inputRef?.current) { + inputRef.current.focus(); + } + }, + [disabled]); + + const handleChange = useCallback((event) => { + setValue(event.target.value); + }, []); + + const handleKeyup = useCallback((event) => { + if (event.code === 'Enter') { + onChange?.(value); + } + }, [value, onChange]); + + const handleFocus = useCallback(() => { + setAutoFocus(true); + }, []); + + const handleBlur = useCallback((event) => { + /* + There is a situation where an input goes from not disabled to disabled and DOM emits a blur + event, with event as undefined. This means, that sometimes we'll receive an React Synthetic + event and sometimes undefined because when DOM triggers the event, React is unaware of it, + or it simply does not emit the event. To bypass this behaviour, we store a local variable + that acts as autofocus. + */ + + if (event) { + setAutoFocus(false); + } + }, []); + + return ( + + + + + + + ); +}; + +QueryField.propTypes = { + disabled: PropTypes.bool, + onChange: PropTypes.func, + placeholder: PropTypes.string, + value: PropTypes.string +}; diff --git a/src/components/quill-editor.js b/src/components/quill-editor.js deleted file mode 100644 index cec7ba1c3de0..000000000000 --- a/src/components/quill-editor.js +++ /dev/null @@ -1,125 +0,0 @@ -import 'react-quill/dist/quill.snow.css'; -import { useRef } from 'react'; -import dynamic from 'next/dynamic'; -import PropTypes from 'prop-types'; -import { styled } from '@mui/material/styles'; - -const Quill = dynamic(() => import('react-quill'), { - ssr: false, - loading: () => null -}); - -const QuillEditorRoot = styled('div')(({ theme }) => ({ - border: 1, - borderColor: theme.palette.divider, - borderRadius: theme.shape.borderRadius, - borderStyle: 'solid', - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - '& .quill': { - display: 'flex', - flex: 1, - flexDirection: 'column', - overflow: 'hidden' - }, - '& .ql-snow.ql-toolbar': { - borderColor: theme.palette.divider, - borderLeft: 'none', - borderRight: 'none', - borderTop: 'none', - '& .ql-picker-label:hover': { - color: theme.palette.primary.main - }, - '& .ql-picker-label.ql-active': { - color: theme.palette.primary.main - }, - '& .ql-picker-item:hover': { - color: theme.palette.primary.main - }, - '& .ql-picker-item.ql-selected': { - color: theme.palette.primary.main - }, - '& button:hover': { - color: theme.palette.primary.main, - '& .ql-stroke': { - stroke: theme.palette.primary.main - } - }, - '& button:focus': { - color: theme.palette.primary.main, - '& .ql-stroke': { - stroke: theme.palette.primary.main - } - }, - '& button.ql-active': { - '& .ql-stroke': { - stroke: theme.palette.primary.main - } - }, - '& .ql-stroke': { - stroke: theme.palette.text.primary - }, - '& .ql-picker': { - color: theme.palette.text.primary - }, - '& .ql-picker-options': { - backgroundColor: theme.palette.background.paper, - border: 'none', - borderRadius: theme.shape.borderRadius, - boxShadow: theme.shadows[10], - padding: theme.spacing(2) - } - }, - '& .ql-snow.ql-container': { - borderBottom: 'none', - borderColor: theme.palette.divider, - borderLeft: 'none', - borderRight: 'none', - display: 'flex', - flex: 1, - flexDirection: 'column', - height: 'auto', - overflow: 'hidden', - '& .ql-editor': { - color: theme.palette.text.primary, - flex: 1, - fontFamily: theme.typography.body1.fontFamily, - fontSize: theme.typography.body1.fontSize, - height: 'auto', - overflowY: 'auto', - padding: theme.spacing(2), - '&.ql-blank::before': { - color: theme.palette.text.secondary, - fontStyle: 'normal', - left: theme.spacing(2) - } - } - } -})); - -export const QuillEditor = (props) => { - const { sx, onChange, placeholder, value, ...other } = props; - const ref = useRef(null); - - return ( - - - - ); -}; - -QuillEditor.propTypes = { - onChange: PropTypes.func, - placeholder: PropTypes.string, - sx: PropTypes.object, - value: PropTypes.string -}; diff --git a/src/components/resource-error.js b/src/components/resource-error.js deleted file mode 100644 index 6438b9d98eb9..000000000000 --- a/src/components/resource-error.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from "prop-types"; -import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon"; -import ExclamationTriangleIcon from "@heroicons/react/24/outline/ExclamationTriangleIcon"; -import { Button, SvgIcon, Typography } from "@mui/material"; -import { styled } from "@mui/material/styles"; - -const ResourceErrorRoot = styled("div")(({ theme }) => ({ - alignItems: "center", - backgroundColor: - theme.palette.mode === "dark" ? theme.palette.neutral[900] : theme.palette.neutral[50], - display: "flex", - flexDirection: "column", - justifyContent: "center", - padding: theme.spacing(3), -})); - -export const ResourceError = (props) => { - const { message = "Something went wrong, please try again.", onReload, sx } = props; - - return ( - - - - - - {message} - - {onReload && ( - - )} - - ); -}; - -ResourceError.propTypes = { - message: PropTypes.string, - onReload: PropTypes.func, - sx: PropTypes.object, -}; diff --git a/src/components/resource-error.jsx b/src/components/resource-error.jsx new file mode 100644 index 000000000000..a6a9364918a4 --- /dev/null +++ b/src/components/resource-error.jsx @@ -0,0 +1,54 @@ +import PropTypes from "prop-types"; +import { CippIcons } from "../utils/icon-registry"; +import { Button, SvgIcon, Typography } from "@mui/material"; +import { styled } from "@mui/material/styles"; + +const ResourceErrorRoot = styled("div")(({ theme }) => ({ + alignItems: "center", + backgroundColor: + theme.palette.mode === "dark" ? theme.palette.neutral[900] : theme.palette.neutral[50], + display: "flex", + flexDirection: "column", + justifyContent: "center", + padding: theme.spacing(3), +})); + +export const ResourceError = (props) => { + const { message = "Something went wrong, please try again.", onReload, sx } = props; + + return ( + + + + + + {message} + + {onReload && ( + + )} + + ); +}; + +ResourceError.propTypes = { + message: PropTypes.string, + onReload: PropTypes.func, + sx: PropTypes.object, +}; diff --git a/src/components/resource-loading.js b/src/components/resource-loading.js deleted file mode 100644 index d80a6b09452a..000000000000 --- a/src/components/resource-loading.js +++ /dev/null @@ -1,33 +0,0 @@ -import PropTypes from "prop-types"; -import { CircularProgress, Typography } from "@mui/material"; -import { styled } from "@mui/material/styles"; - -const ResourceLoadingRoot = styled("div")(({ theme }) => ({ - alignItems: "center", - backgroundColor: - theme.palette.mode === "dark" ? theme.palette.neutral[900] : theme.palette.neutral[50], - display: "flex", - flexDirection: "column", - justifyContent: "center", - padding: theme.spacing(3), -})); - -export const ResourceLoading = (props) => { - const { message, sx } = props; - - return ( - - - {message && ( - - {message} - - )} - - ); -}; - -ResourceLoading.propTypes = { - message: PropTypes.string, - sx: PropTypes.object, -}; diff --git a/src/components/resource-loading.jsx b/src/components/resource-loading.jsx new file mode 100644 index 000000000000..9284a2283eea --- /dev/null +++ b/src/components/resource-loading.jsx @@ -0,0 +1,38 @@ +import PropTypes from "prop-types"; +import { CircularProgress, Typography } from "@mui/material"; +import { styled } from "@mui/material/styles"; + +const ResourceLoadingRoot = styled("div")(({ theme }) => ({ + alignItems: "center", + backgroundColor: + theme.palette.mode === "dark" ? theme.palette.neutral[900] : theme.palette.neutral[50], + display: "flex", + flexDirection: "column", + justifyContent: "center", + padding: theme.spacing(3), +})); + +export const ResourceLoading = (props) => { + const { message, sx } = props; + + return ( + + + {message && ( + + {message} + + )} + + ); +}; + +ResourceLoading.propTypes = { + message: PropTypes.string, + sx: PropTypes.object, +}; diff --git a/src/components/resource-unavailable.js b/src/components/resource-unavailable.js deleted file mode 100644 index cc5e22c0b8f2..000000000000 --- a/src/components/resource-unavailable.js +++ /dev/null @@ -1,64 +0,0 @@ -import PropTypes from "prop-types"; -import PlusIcon from "@heroicons/react/24/outline/PlusIcon"; -import { Box, Button, SvgIcon, Typography } from "@mui/material"; -import { styled } from "@mui/material/styles"; -import { useTheme } from "@emotion/react"; - -const ResourceUnavailableRoot = styled("div")(({ theme }) => ({ - alignItems: "center", - backgroundColor: - theme.palette.mode === "dark" ? theme.palette.neutral[900] : theme.palette.neutral[50], - display: "flex", - flexDirection: "column", - justifyContent: "center", - padding: theme.spacing(3), -})); - -export const ResourceUnavailable = (props) => { - const { message = "", onCreate, createButtonText, sx, type, target } = props; - const theme = useTheme(); - - return ( - - - - - {message && ( - - {message} - - )} - {onCreate && ( - - )} - - ); -}; - -ResourceUnavailable.propTypes = { - message: PropTypes.string, - onCreate: PropTypes.func, - sx: PropTypes.object, -}; diff --git a/src/components/resource-unavailable.jsx b/src/components/resource-unavailable.jsx new file mode 100644 index 000000000000..fde923f7f829 --- /dev/null +++ b/src/components/resource-unavailable.jsx @@ -0,0 +1,69 @@ +import PropTypes from "prop-types"; +import { CippIcons } from "../utils/icon-registry"; +import { Box, Button, SvgIcon, Typography } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { useTheme } from "@emotion/react"; + +const ResourceUnavailableRoot = styled("div")(({ theme }) => ({ + alignItems: "center", + backgroundColor: + theme.palette.mode === "dark" ? theme.palette.neutral[900] : theme.palette.neutral[50], + display: "flex", + flexDirection: "column", + justifyContent: "center", + padding: theme.spacing(3), +})); + +export const ResourceUnavailable = (props) => { + const { message = "", onCreate, createButtonText, sx, type, target } = props; + const theme = useTheme(); + + return ( + + + + + {message && ( + + {message} + + )} + {onCreate && ( + + )} + + ); +}; + +ResourceUnavailable.propTypes = { + message: PropTypes.string, + onCreate: PropTypes.func, + sx: PropTypes.object, +}; diff --git a/src/components/rtl.js b/src/components/rtl.jsx similarity index 100% rename from src/components/rtl.js rename to src/components/rtl.jsx diff --git a/src/components/toaster.js b/src/components/toaster.js deleted file mode 100644 index 933adf625de1..000000000000 --- a/src/components/toaster.js +++ /dev/null @@ -1,50 +0,0 @@ -import { CloseSharp } from "@mui/icons-material"; -import { Alert, IconButton, Snackbar } from "@mui/material"; -import { useSelector } from "react-redux"; -import { useDispatch } from "react-redux"; -import { closeToast } from "../store/toasts"; - -const Toasts = () => { - const dispatch = useDispatch(); - const toasts = useSelector((state) => state.toasts.toasts); - - return ( - <> - {[ - toasts.map((toast) => ( - dispatch(closeToast({ index: toast.index }))} - action={ - <> - dispatch(closeToast({ index: toast.index }))} - > - - - - } - > - dispatch(closeToast({ index: toast.index }))} - severity="error" - variant="filled" - sx={{ width: "100%" }} - > - {toast.toastError.status} - {toast.message} - - - )), - ]} - - ); -}; - -export default Toasts; diff --git a/src/components/toaster.jsx b/src/components/toaster.jsx new file mode 100644 index 000000000000..a2abbc609daa --- /dev/null +++ b/src/components/toaster.jsx @@ -0,0 +1,50 @@ +import { Alert, IconButton, Snackbar } from "@mui/material"; +import { CippIcons } from "../utils/icon-registry"; +import { useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; +import { closeToast } from "../store/toasts"; + +const Toasts = () => { + const dispatch = useDispatch(); + const toasts = useSelector((state) => state.toasts.toasts); + + return ( + <> + {[ + toasts.map((toast) => ( + dispatch(closeToast({ index: toast.index }))} + action={ + <> + dispatch(closeToast({ index: toast.index }))} + > + + + + } + > + dispatch(closeToast({ index: toast.index }))} + severity="error" + variant="filled" + sx={{ width: "100%" }} + > + {toast.toastError.status} - {toast.message} + + + )), + ]} + + ); +}; + +export default Toasts; diff --git a/src/components/widget-previewer.js b/src/components/widget-previewer.js deleted file mode 100644 index 396fa976f765..000000000000 --- a/src/components/widget-previewer.js +++ /dev/null @@ -1,41 +0,0 @@ -import PropTypes from 'prop-types'; -import { Card, Stack, Typography } from '@mui/material'; - -export const WidgetPreviewer = (props) => { - const { title, description, children, ...other } = props; - - return ( - - - {typeof title === 'string' - ? ( - - {title} - - ) - : title} - {typeof description === 'string' - ? ( - - {description} - - ) - : description} - - - {children} - - - ); -}; - -WidgetPreviewer.propTypes = { - children: PropTypes.node.isRequired, - description: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), - title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) -}; diff --git a/src/components/widget-previewer.jsx b/src/components/widget-previewer.jsx new file mode 100644 index 000000000000..dd7e0d041a28 --- /dev/null +++ b/src/components/widget-previewer.jsx @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import { Card, Stack, Typography } from '@mui/material'; + +export const WidgetPreviewer = (props) => { + const { title, description, children, ...other } = props; + + return ( + + + {typeof title === 'string' + ? ( + + {title} + + ) + : title} + {typeof description === 'string' + ? ( + + {description} + + ) + : description} + + + {children} + + + ); +}; + +WidgetPreviewer.propTypes = { + children: PropTypes.node.isRequired, + description: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; diff --git a/src/contexts/release-notes-context.js b/src/contexts/release-notes-context.jsx similarity index 100% rename from src/contexts/release-notes-context.js rename to src/contexts/release-notes-context.jsx diff --git a/src/contexts/settings-context.js b/src/contexts/settings-context.jsx similarity index 100% rename from src/contexts/settings-context.js rename to src/contexts/settings-context.jsx diff --git a/src/contexts/tutorial-context.js b/src/contexts/tutorial-context.jsx similarity index 100% rename from src/contexts/tutorial-context.js rename to src/contexts/tutorial-context.jsx diff --git a/src/data/AuditLogSchema.json b/src/data/AuditLogSchema.json index 93122adbc4cf..1c0fcef5a6c6 100644 --- a/src/data/AuditLogSchema.json +++ b/src/data/AuditLogSchema.json @@ -317,6 +317,42 @@ { "value": "Update StsRefreshTokenValidFrom Timestamp.", "label": "Update StsRefreshTokenValidFrom Timestamp." + }, + { + "value": "Add member to role in PIM completed (permanent)", + "label": "added a permanent role member in PIM" + }, + { + "value": "Add member to role in PIM completed (timebound)", + "label": "added a time-bound role member in PIM" + }, + { + "value": "Add eligible member to role in PIM completed (permanent)", + "label": "added a permanent eligible role member in PIM" + }, + { + "value": "Add eligible member to role in PIM completed (timebound)", + "label": "added a time-bound eligible role member in PIM" + }, + { + "value": "Add member to role completed (PIM activation)", + "label": "activated a role in PIM" + }, + { + "value": "Remove member from role completed (PIM activation expired)", + "label": "PIM role activation expired" + }, + { + "value": "Remove member from role in PIM completed (permanent)", + "label": "removed a permanent role member in PIM" + }, + { + "value": "Remove eligible member from role in PIM completed (permanent)", + "label": "removed a permanent eligible role member in PIM" + }, + { + "value": "Update role setting in PIM", + "label": "updated a PIM role setting" } ], "List:LogonType": [ diff --git a/src/data/AuditLogTemplates.json b/src/data/AuditLogTemplates.json index 3999de345b0f..dafc72bdb839 100644 --- a/src/data/AuditLogTemplates.json +++ b/src/data/AuditLogTemplates.json @@ -493,5 +493,92 @@ } ] } + }, + { + "value": "PIMPermanentAssignmentAdded", + "name": "A permanent role assignment was added in PIM (no end date)", + "template": { + "preset": { + "value": "PIMPermanentAssignmentAdded", + "label": "A permanent role assignment was added in PIM (no end date)" + }, + "logbook": { + "value": "Audit.AzureActiveDirectory", + "label": "Azure AD" + }, + "conditions": [ + { + "Property": { + "value": "String", + "label": "Operation" + }, + "Operator": { + "value": "like", + "label": "Like" + }, + "Input": { + "value": "*role in PIM completed (permanent)*" + } + } + ] + } + }, + { + "value": "PIMRoleActivated", + "name": "A PIM role was activated", + "template": { + "preset": { + "value": "PIMRoleActivated", + "label": "A PIM role was activated" + }, + "logbook": { + "value": "Audit.AzureActiveDirectory", + "label": "Azure AD" + }, + "conditions": [ + { + "Property": { + "value": "String", + "label": "Operation" + }, + "Operator": { + "value": "like", + "label": "Like" + }, + "Input": { + "value": "*Add member to role completed (PIM activation)*" + } + } + ] + } + }, + { + "value": "PIMRoleSettingChanged", + "name": "A PIM role setting was changed", + "template": { + "preset": { + "value": "PIMRoleSettingChanged", + "label": "A PIM role setting was changed" + }, + "logbook": { + "value": "Audit.AzureActiveDirectory", + "label": "Azure AD" + }, + "conditions": [ + { + "Property": { + "value": "String", + "label": "Operation" + }, + "Operator": { + "value": "like", + "label": "Like" + }, + "Input": { + "value": "*Update role setting in PIM*" + } + } + ] + } } ] diff --git a/src/data/CIPPDBCacheTypes.json b/src/data/CIPPDBCacheTypes.json index 0b5b85e60bb5..e996973c3e1e 100644 --- a/src/data/CIPPDBCacheTypes.json +++ b/src/data/CIPPDBCacheTypes.json @@ -261,6 +261,7 @@ }, { "type": "OneDriveSiteListing", + "collectedBy": "OneDriveUsage", "friendlyName": "OneDrive Site Listing", "description": "OneDrive personal site listing details used for usage reporting" }, @@ -269,8 +270,14 @@ "friendlyName": "OneDrive Usage", "description": "OneDrive usage statistics" }, + { + "type": "OneDriveLongPaths", + "friendlyName": "OneDrive Long Paths", + "description": "Per-user counts of OneDrive paths that may exceed Windows 260-character sync limits or the cloud 400-character ceiling (no file or folder names stored)" + }, { "type": "SharePointSiteListing", + "collectedBy": "SharePointSiteUsage", "friendlyName": "SharePoint Site Listing", "description": "SharePoint site listing details used for usage reporting" }, @@ -279,6 +286,11 @@ "friendlyName": "SharePoint Site Usage", "description": "SharePoint site usage statistics" }, + { + "type": "SPOSites", + "friendlyName": "SharePoint Site Settings", + "description": "Per-site SharePoint admin settings (site owner, sharing controls, lifecycle, version policy, People Picker, unmanaged-device access) for every site collection, keyed by site id" + }, { "type": "SiteActivity", "friendlyName": "Site Activity", @@ -294,6 +306,11 @@ "friendlyName": "SharePoint Permissions", "description": "Site and document library permission assignments, including libraries that no longer inherit and grants to tenant-wide claims such as Everyone except external users" }, + { + "type": "StorageCleanupScan", + "friendlyName": "Storage Cleanup Scan", + "description": "Per-site library version estimates and recycle totals for the storage report cleanup signals. Report-private; not used by other CIPP features." + }, { "type": "ConditionalAccessPolicies", "friendlyName": "Conditional Access Policies", diff --git a/src/data/Extensions.json b/src/data/Extensions.json index 660661bf91e5..b9146ab3fdc5 100644 --- a/src/data/Extensions.json +++ b/src/data/Extensions.json @@ -1080,6 +1080,7 @@ "type": "ConnectWisePSA", "cat": "Ticketing", "logo": "/assets/integrations/connectwise.png", + "logoNeedsLightBacking": true, "comingSoon": true, "description": "Enable the ConnectWise PSA integration to send alerts to your ticketing system.", "mappingRequired": false @@ -1090,6 +1091,7 @@ "type": "AutotaskPSA", "cat": "Ticketing", "logo": "/assets/integrations/autotask.png", + "logoNeedsLightBacking": true, "comingSoon": true, "description": "Enable the Autotask PSA integration to send alerts to your ticketing system.", "mappingRequired": false @@ -1100,6 +1102,7 @@ "type": "KaseyaBMS", "cat": "Ticketing", "logo": "/assets/integrations/kaseya.svg", + "logoNeedsLightBacking": true, "comingSoon": true, "description": "Enable the Kaseya BMS integration to send alerts to your ticketing system.", "mappingRequired": false diff --git a/src/data/alerts.json b/src/data/alerts.json index adc8d26de373..d4119cddd887 100644 --- a/src/data/alerts.json +++ b/src/data/alerts.json @@ -189,7 +189,27 @@ ] } ], - "recommendedRunInterval": "1d" + "recommendedRunInterval": "1d", + "description": "Alerts when a mailbox exceeds a percentage of its send/receive quota, using the Microsoft mailbox usage report. The report's storage figure already excludes the Recoverable Items dumpster and the archive, so it reflects what actually counts against the quota. Deleted mailboxes are skipped." + }, + { + "name": "ArchiveQuota", + "label": "Alert on % online archive quota used", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "textField", + "inputLabel": "Archive quota percentage threshold (default: 90)", + "inputName": "ArchiveQuotaThreshold" + }, + { + "inputType": "textField", + "inputLabel": "Excluded mailbox UPNs (comma-separated). Supports custom variables like %excludefromarchivealert% defined under CIPP > Settings > Custom Variables at tenant or AllTenants scope.", + "inputName": "ArchiveQuotaExcludedMailboxes" + } + ], + "recommendedRunInterval": "7d" }, { "name": "SharePointQuota", @@ -209,6 +229,16 @@ "inputName": "OneDriveQuota", "recommendedRunInterval": "1d" }, + { + "name": "OneDriveLongPaths", + "label": "Alert on OneDrive accounts with over-long paths", + "requiresInput": true, + "inputType": "textField", + "inputLabel": "Minimum paths over inferred 260-character limit (default: 1)", + "inputName": "OneDriveLongPaths", + "recommendedRunInterval": "7d", + "description": "Uses the OneDriveLongPaths Report DB cache (run ExecCIPPDBCache OneDriveLongPaths first). Signals users who may hit OneDrive sync or Office path errors. Alert and cache store owner UPN and counts only — never file or folder names." + }, { "name": "UnlicensedOneDriveData", "label": "Alert on leftover unlicensed OneDrive data nearing deletion", @@ -241,12 +271,18 @@ "inputLabel": "Days until expiration (default: 30)", "inputName": "ExpiringLicensesDays" }, + { + "inputType": "number", + "inputLabel": "Minimum days until expiration (default: 0)", + "inputName": "ExpiringLicensesMinDays" + }, { "inputType": "switch", "inputLabel": "Alert only on unassigned licenses", "inputName": "ExpiringLicensesUnassignedOnly" } - ] + ], + "description": "Alerts on licenses with a renewal date within the selected number of days. Microsoft does not expose the actual commitment term, so monthly auto-renewing subscriptions also match; set the minimum days to 32 or higher to skip anything renewing sooner than that (such as monthly rollovers)." }, { "name": "NewAppApproval", @@ -415,7 +451,8 @@ { "name": "AppSecretExpiry", "label": "Alert on expiring application secrets", - "recommendedRunInterval": "1d" + "recommendedRunInterval": "1d", + "description": "Reports app registration client secrets expiring within the next 30 days, and keeps reporting them for 7 days after they expire. Secrets that expired more than 7 days ago are not reported." }, { "name": "AppleTerms", @@ -440,17 +477,29 @@ { "name": "ApnCertExpiry", "label": "Alert on expiring APN certificates", - "recommendedRunInterval": "1d" + "recommendedRunInterval": "1d", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Days until expiry to alert (default: 30)", + "inputName": "DaysUntilExpiry" }, { "name": "VppTokenExpiry", "label": "Alert on expiring VPP tokens", - "recommendedRunInterval": "1d" + "recommendedRunInterval": "1d", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Days until expiry to alert (default: 30)", + "inputName": "DaysUntilExpiry" }, { "name": "DepTokenExpiry", "label": "Alert on expiring DEP tokens", - "recommendedRunInterval": "1d" + "recommendedRunInterval": "1d", + "requiresInput": true, + "inputType": "number", + "inputLabel": "Days until expiry to alert (default: 30)", + "inputName": "DaysUntilExpiry" }, { "name": "IntuneApprovalRequests", @@ -468,6 +517,21 @@ "label": "Alert on device compliance issues", "recommendedRunInterval": "4h" }, + { + "name": "DeviceComplianceGracePeriod", + "label": "Alert on devices in the compliance grace period", + "recommendedRunInterval": "1d", + "description": "Devices in the compliance grace period are still trusted, but will be marked noncompliant when the grace period expires. This alerts before that happens so you can fix the device without user impact.", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "number", + "inputLabel": "Only alert when the grace period expires within this many days (default: all devices in grace period)", + "inputName": "ExpiresWithinDays" + } + ] + }, { "name": "IntunePolicyConflicts", "label": "Alert on Intune policy or app conflicts/errors", @@ -717,5 +781,25 @@ "inputLabel": "Ignore sanctioned tools?", "inputName": "IgnoreSanctionedTools", "description": "Monitors the Shadow AI detection data (Intune detected apps and Entra consented applications) and alerts when an AI tool is seen in the tenant for the first time. The first run establishes a baseline without alerting. Uses the same cached data as the Shadow AI dashboard, so keep the Detected Apps and Service Principals caches syncing. Optionally ignores tools that have been marked as company sanctioned." + }, + { + "name": "PermanentActiveAdminAssigned", + "label": "Alert on new permanent (no end date) admin role assignments", + "recommendedRunInterval": "1h", + "requiresInput": true, + "multipleInput": true, + "inputs": [ + { + "inputType": "textField", + "inputLabel": "Approved break-glass accounts: UPN prefixes, UPNs or display names (comma separated)", + "inputName": "ApprovedAdmins" + }, + { + "inputType": "switch", + "inputLabel": "Privileged roles only? (off = every directory role)", + "inputName": "PrivilegedRolesOnly" + } + ], + "description": "Alerts when a user, group or service principal gains a permanent active admin role assignment (no end date) that is not in the approved list. On Entra ID P2 tenants time-bound and PIM-activated assignments are ignored; elsewhere every assignment counts as permanent. The first run only records the current state." } ] diff --git a/src/data/standards.json b/src/data/standards.json index 57c45550d2fb..5e79d23e0fcc 100644 --- a/src/data/standards.json +++ b/src/data/standards.json @@ -582,15 +582,23 @@ "name": "standards.AuthenticationMethods", "cat": "Entra (AAD) Standards", "tag": [], - "helpText": "Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Enable or disable each method and optionally target specific groups.", - "docsDescription": "Unified standard to configure all authentication method policies in a single place. Each method can be independently enabled or disabled, targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP.", + "helpText": "Configures all authentication methods for the tenant including Microsoft Authenticator, FIDO2, SMS, Voice, Email OTP, Temporary Access Pass, Software OATH, Hardware OATH, Certificate-based, and QR Code Pin. Set each method to Enabled, Disabled or Not Configured and optionally target specific groups. Methods set to Not Configured (or left blank) keep the tenant's current setting.", + "docsDescription": "Unified standard to configure all authentication method policies in a single place. Each method can be independently set to Enabled, Disabled or Not Configured (leaving the tenant's current configuration untouched), targeted to all users or specific groups using group name wildcards, and configured with method-specific settings such as TAP lifetime, QR code pin length, and Authenticator software OTP.", "executiveText": "Provides centralized control over all tenant authentication methods from a single standard. Administrators can enable phishing-resistant methods like FIDO2 and Microsoft Authenticator while disabling less secure options like SMS and Voice. Each method supports group-level targeting using wildcard group names, allowing staged rollouts and granular control.", "addedComponent": [ { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", "label": "Microsoft Authenticator", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -599,7 +607,7 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -610,7 +618,7 @@ "defaultValue": false, "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -627,7 +635,7 @@ ], "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -644,7 +652,7 @@ ], "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -661,7 +669,7 @@ ], "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -678,15 +686,23 @@ ], "condition": { "field": "standards.AuthenticationMethods.MicrosoftAuthenticatorEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.FIDO2Enabled", "label": "FIDO2 Security Keys", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -695,15 +711,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.FIDO2Enabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.TAPEnabled", "label": "Temporary Access Pass", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -712,7 +736,7 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -728,7 +752,7 @@ ], "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -739,7 +763,7 @@ "defaultValue": 60, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -750,7 +774,7 @@ "defaultValue": 60, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -761,7 +785,7 @@ "defaultValue": 480, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -772,15 +796,23 @@ "defaultValue": 8, "condition": { "field": "standards.AuthenticationMethods.TAPEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.SoftwareOathEnabled", "label": "Third-Party Software OATH Tokens", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -789,15 +821,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.SoftwareOathEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.HardwareOathEnabled", "label": "Hardware OATH Tokens", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -806,15 +846,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.HardwareOathEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.SMSEnabled", "label": "SMS", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -823,15 +871,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.SMSEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.VoiceEnabled", "label": "Voice Call", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -840,15 +896,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.VoiceEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.EmailEnabled", "label": "Email OTP", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -857,15 +921,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.EmailEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.x509CertificateEnabled", "label": "Certificate-Based Authentication", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -874,15 +946,23 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.x509CertificateEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, { - "type": "switch", + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, "name": "standards.AuthenticationMethods.QRCodePinEnabled", "label": "QR Code Pin", - "defaultValue": false + "helperText": "Not Configured or blank leaves the tenant's current setting untouched.", + "options": [ + { "label": "Enabled", "value": true }, + { "label": "Disabled", "value": false }, + { "label": "Not Configured", "value": "notConfigured" } + ] }, { "type": "textField", @@ -891,7 +971,7 @@ "required": false, "condition": { "field": "standards.AuthenticationMethods.QRCodePinEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -902,7 +982,7 @@ "defaultValue": 365, "condition": { "field": "standards.AuthenticationMethods.QRCodePinEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } }, @@ -913,7 +993,7 @@ "defaultValue": 8, "condition": { "field": "standards.AuthenticationMethods.QRCodePinEnabled", - "compareType": "is", + "compareType": "valueEq", "compareValue": true } } @@ -999,7 +1079,8 @@ "labelField": "TemplateName", "valueField": "TemplateId", "queryKey": "StdAppApprovalTemplateList", - "addedField": { "AppId": "AppId" } + "addedField": { "AppId": "AppId" }, + "templateView": { "title": "App Approval Template" } }, "condition": { "field": "standards.AppDeploy.mode", @@ -1353,6 +1434,32 @@ "powershellEquivalent": "Update-MgBetaPolicyCrossTenantAccessPolicyDefault", "recommendedBy": [] }, + { + "name": "standards.ExternalComplianceTrusted", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Sets the state of the Cross-tenant access setting to trust external compliant devices. This allows guest users to use a compliant device from their home tenant to access your tenant.", + "executiveText": "Allows external partners and vendors to use compliant devices from their own organization when accessing company resources, streamlining collaboration while maintaining security standards. This reduces friction for external users while ensuring their devices still meet compliance requirements.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Select value", + "name": "standards.ExternalComplianceTrusted.state", + "options": [ + { "label": "Enabled", "value": "true" }, + { "label": "Disabled", "value": "false" } + ] + } + ], + "label": "Sets the Cross-tenant access setting to trust external compliant devices", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-08-25", + "powershellEquivalent": "Update-MgBetaPolicyCrossTenantAccessPolicyDefault", + "recommendedBy": [] + }, { "name": "standards.DisableTenantCreation", "cat": "Entra (AAD) Standards", @@ -1661,7 +1768,7 @@ "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.8)"], "appliesToTest": ["SMB1001_2_8", "ZTNA21858"], - "helpText": "Blocks login for guest users that have not logged in for a number of days", + "helpText": "Blocks login for guest users whose most recent sign-in attempt, interactive or non-interactive, is older than the number of days. Guests that have never signed in are only included when 'Disable accounts that have not yet signed in' is enabled. Accounts an administrator re-enabled in the last 7 days are left alone.", "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", "addedComponent": [ { @@ -1670,6 +1777,12 @@ "required": true, "defaultValue": 90, "label": "Days of inactivity" + }, + { + "type": "switch", + "name": "standards.DisableGuests.IncludeNeverSignedIn", + "label": "Disable accounts that have not yet signed in", + "defaultValue": false } ], "label": "Disable Guest accounts that have not logged on for a number of days", @@ -2128,6 +2241,16 @@ "type": "textField", "name": "standards.OutBoundSpamAlert.OutboundSpamContact", "label": "Outbound spam contact" + }, + { + "type": "switch", + "name": "standards.OutBoundSpamAlert.BccSuspiciousOutboundMail", + "label": "BCC suspicious outbound mail to a mailbox" + }, + { + "type": "textField", + "name": "standards.OutBoundSpamAlert.BccSuspiciousOutboundContact", + "label": "BCC recipient for suspicious outbound mail" } ], "label": "Set Outbound Spam Alert e-mail", @@ -3322,7 +3445,7 @@ "tag": ["CIS M365 7.0.0 (8.6.1)"], "appliesToTest": ["CIS_8_6_1"], "helpText": "Set the state of the spam submission button in Outlook", - "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish.", + "docsDescription": "Set the state of the built-in Report button in Outlook. This gives the users the ability to report emails as spam or phish. When a destination email address is set, the 'Send reported items to' setting controls whether reported messages go to Microsoft and the reporting mailbox, or to the reporting mailbox only (for third-party phishing report services).", "executiveText": "Enables employees to easily report suspicious emails directly from Outlook, helping improve the organization's spam and phishing detection systems. This crowdsourced approach to security allows users to contribute to threat detection while providing valuable feedback to enhance email security filters.", "addedComponent": [ { @@ -3340,6 +3463,16 @@ "name": "standards.UserSubmissions.email", "required": false, "label": "Destination email address" + }, + { + "type": "autoComplete", + "multiple": false, + "label": "Send reported items to (when a destination email address is set)", + "name": "standards.UserSubmissions.reportDestination", + "options": [ + { "label": "Microsoft and my reporting mailbox", "value": "Both" }, + { "label": "My reporting mailbox only", "value": "Mailbox" } + ] } ], "label": "Set the state of the built-in Report button in Outlook", @@ -4309,6 +4442,18 @@ } ] }, + { + "type": "autoComplete", + "required": false, + "multiple": false, + "creatable": false, + "label": "Bulk moves enabled (deliver bulk mail below the threshold to the Promotions folder - Preview)", + "name": "standards.SpamFilterPolicy.BulkMovesEnabled", + "options": [ + { "label": "On", "value": "On" }, + { "label": "Off", "value": "Off" } + ] + }, { "type": "autoComplete", "required": true, @@ -4779,7 +4924,7 @@ "cat": "Intune Standards", "tag": ["CIS M365 7.0.0 (4.2)", "CISA (MS.AAD.19.1v1)"], "appliesToTest": ["CIS_4_2"], - "helpText": "Sets the default platform restrictions for enrolling devices into Intune. Note: Do not block personally owned if platform is blocked.", + "helpText": "Sets the default platform restrictions for enrolling devices into Intune, including optional minimum and maximum OS version limits per platform (Android Enterprise, Android, iOS/iPadOS and Windows). Note: Do not block personally owned if platform is blocked.", "executiveText": "Controls which types of devices (iOS, Android, Windows, macOS) and ownership models (corporate vs. personal) can be enrolled in the company's device management system. This helps maintain security standards while supporting necessary business device types and usage scenarios.", "addedComponent": [ { @@ -4794,6 +4939,20 @@ "label": "Block personally owned Android Enterprise (work profile)", "default": false }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMinimumVersionAndroidForWork", + "label": "Android Enterprise (work profile) minimum OS version", + "helperText": "Example: 11.0. Leave blank to not enforce a minimum.", + "required": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMaximumVersionAndroidForWork", + "label": "Android Enterprise (work profile) maximum OS version", + "helperText": "Example: 14.0. Leave blank to not enforce a maximum.", + "required": false + }, { "type": "switch", "name": "standards.DefaultPlatformRestrictions.platformAndroidBlocked", @@ -4806,6 +4965,20 @@ "label": "Block personally owned Android", "default": false }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMinimumVersionAndroid", + "label": "Android minimum OS version", + "helperText": "Example: 10.0. Leave blank to not enforce a minimum.", + "required": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMaximumVersionAndroid", + "label": "Android maximum OS version", + "helperText": "Example: 13.0. Leave blank to not enforce a maximum.", + "required": false + }, { "type": "switch", "name": "standards.DefaultPlatformRestrictions.platformiOSBlocked", @@ -4818,6 +4991,20 @@ "label": "Block personally owned iOS", "default": false }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMinimumVersioniOS", + "label": "iOS/iPadOS minimum OS version", + "helperText": "Example: 16.1. Leave blank to not enforce a minimum.", + "required": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMaximumVersioniOS", + "label": "iOS/iPadOS maximum OS version", + "helperText": "Example: 18.0. Leave blank to not enforce a maximum.", + "required": false + }, { "type": "switch", "name": "standards.DefaultPlatformRestrictions.platformMacOSBlocked", @@ -4841,6 +5028,20 @@ "name": "standards.DefaultPlatformRestrictions.personalWindowsBlocked", "label": "Block personally owned Windows", "default": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMinimumVersionWindows", + "label": "Windows minimum OS version", + "helperText": "Example: 10.0.19045.0. Leave blank to not enforce a minimum.", + "required": false + }, + { + "type": "textField", + "name": "standards.DefaultPlatformRestrictions.osMaximumVersionWindows", + "label": "Windows maximum OS version", + "helperText": "Example: 10.0.22631.0. Leave blank to not enforce a maximum.", + "required": false } ], "label": "Device enrollment restrictions", @@ -5261,6 +5462,40 @@ "ONEDRIVE_ENTERPRISE" ] }, + { + "name": "standards.SPGuestPeoplePicker", + "cat": "SharePoint Standards", + "tag": [], + "helpText": "Controls whether guest (external) users already in the tenant appear as suggestions in the SharePoint and OneDrive People Picker. Enforces the wanted state on BOTH the tenant default and every existing site collection - they are set independently, so changing the tenant default does not update existing sites. The per-site picture is read from the SharePoint reporting cache (refreshed daily), so a large tenant is never enumerated live during a run; a 24h rerun guard stops the write sweep from repeating before that cache refreshes and re-evaluates the result.", + "executiveText": "Makes existing external collaborators discoverable (or hidden) when sharing SharePoint and OneDrive content, consistently across the tenant default and every existing site. This keeps the sharing experience predictable and prevents individual sites from drifting away from the agreed collaboration posture.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Guest People Picker suggestions", + "name": "standards.SPGuestPeoplePicker.state", + "options": [ + { "label": "Show guests in the People Picker", "value": "true" }, + { "label": "Hide guests in the People Picker", "value": "false" } + ] + } + ], + "label": "Show guest users in the SharePoint People Picker", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-09-03", + "powershellEquivalent": "Set-SPOTenant / Set-SPOSite -ShowPeoplePickerSuggestionsForGuestUsers $true or $false", + "recommendedBy": ["CIPP"], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ] + }, { "name": "standards.SPAzureB2B", "cat": "SharePoint Standards", @@ -5801,7 +6036,7 @@ "CIS_8_5_8", "CIS_8_5_9" ], - "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl", + "helpText": "Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl, AllowExternalNonTrustedMeetingChat, AllowCloudRecording", "executiveText": "Establishes security-focused default settings for Teams meetings, controlling who can join meetings, present content, and participate in chats. These policies balance collaboration needs with security requirements, ensuring meetings remain productive while protecting against unauthorized access and disruption.", "addedComponent": [ { @@ -5876,13 +6111,39 @@ "type": "switch", "name": "standards.TeamsGlobalMeetingPolicy.AllowExternalParticipantGiveRequestControl", "label": "External participants can give or request control" + }, + { + "type": "autoComplete", + "required": false, + "multiple": false, + "creatable": false, + "name": "standards.TeamsGlobalMeetingPolicy.AllowExternalNonTrustedMeetingChat", + "label": "External meeting chat", + "helperText": "CIS 8.5.8 recommends Off. Leave blank to keep the tenant's current value.", + "options": [ + { "label": "Off (CIS recommended)", "value": false }, + { "label": "On", "value": true } + ] + }, + { + "type": "autoComplete", + "required": false, + "multiple": false, + "creatable": false, + "name": "standards.TeamsGlobalMeetingPolicy.AllowCloudRecording", + "label": "Meeting cloud recording", + "helperText": "CIS 8.5.9 recommends Off. Leave blank to keep the tenant's current value.", + "options": [ + { "label": "Off (CIS recommended)", "value": false }, + { "label": "On", "value": true } + ] } ], "label": "Define Global Meeting Policy for Teams", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-11-12", - "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers $AutoAdmittedUsers -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false -AllowParticipantGiveRequestControl $false", + "powershellEquivalent": "Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting $false -AllowAnonymousUsersToStartMeeting $false -AutoAdmittedUsers $AutoAdmittedUsers -AllowPSTNUsersToBypassLobby $false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode $DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl $false -AllowParticipantGiveRequestControl $false -AllowExternalNonTrustedMeetingChat $false -AllowCloudRecording $false", "recommendedBy": ["CIS"], "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, @@ -8092,7 +8353,8 @@ "url": "/api/ListAppTemplates", "labelField": "displayName", "valueField": "GUID", - "queryKey": "StdIntuneAppTemplateList" + "queryKey": "StdIntuneAppTemplateList", + "templateView": { "title": "Application Template" } } } ], @@ -8493,5 +8755,50 @@ "warn": false, "remediate": false } + }, + { + "name": "standards.PIMRoleSettings", + "label": "PIM Role Settings Template", + "cat": "Templates", + "multiple": true, + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "impact": "High Impact", + "impactColour": "danger", + "addedDate": "2026-08-23", + "tag": [], + "helpText": "Deploys a Privileged Identity Management role settings template to the tenant: activation limits, MFA or authentication context, justification, approval, eligibility and active-assignment expiry and notification rules for the roles the template covers. Templates cannot weaken settings below CIPP's secure floor.", + "docsDescription": "Deploys a Privileged Identity Management role settings template to the tenant. The template defines, for a set of roles, the maximum activation duration, whether activation requires MFA or an authentication context, justification, ticket and approval requirements, the maximum lifetime of eligible and active assignments, and additional notification recipients. Templates are validated against CIPP's secure floor (activation within 24 hours with MFA or an authentication context and a justification; eligible and active assignments must expire within a year; active assignments require a justification) and are refused, not adjusted, when they fall below it. Requires Entra ID P2.", + "executiveText": "Enforces consistent Privileged Identity Management settings so that administrator roles can only be used for a limited time, after strong authentication and with a recorded reason. This keeps standing administrative access to a minimum and makes every use of privilege visible and accountable.", + "addedComponent": [ + { + "type": "autoComplete", + "name": "TemplateList", + "multiple": false, + "required": true, + "creatable": false, + "label": "Select PIM Role Settings Template", + "api": { + "url": "/api/ListPIMRoleSettingsTemplates", + "labelField": "templateName", + "valueField": "GUID", + "queryKey": "ListPIMRoleSettingsTemplates", + "showRefresh": true, + "templateView": { + "title": "PIM Role Settings Template" + } + } + } + ], + "powershellEquivalent": "Update-MgBetaPolicyRoleManagementPolicyRule", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [ + "AAD_PREMIUM_P2" + ] } ] diff --git a/src/hooks/use-actions-dispatch.jsx b/src/hooks/use-actions-dispatch.jsx index bad8a7f87968..1e085b019bcd 100644 --- a/src/hooks/use-actions-dispatch.jsx +++ b/src/hooks/use-actions-dispatch.jsx @@ -76,7 +76,7 @@ export const useActionsDispatch = ({ actions = [], data, queryKeys }) => { // ordering gave them — only the structural props above change hands. title={actionData.action?.title ?? "Confirmation"} relatedQueryKeys={actionData.action?.relatedQueryKeys ?? queryKeys} - TransitionProps={{ onExited: handleExited }} + slotProps={{ transition: { onExited: handleExited } }} /> )} {customAction?.action?.customComponent(customAction.data, { diff --git a/src/hooks/use-jit-allowed-roles.js b/src/hooks/use-jit-allowed-roles.js new file mode 100644 index 000000000000..35860327cb5c --- /dev/null +++ b/src/hooks/use-jit-allowed-roles.js @@ -0,0 +1,30 @@ +import { ApiGetCall } from "../api/ApiCall"; + +/** + * Resolves which directory roles the current user may assign via JIT Admin, based on the JIT Role + * Template(s) attached to their CIPP custom role(s). When the user is unrestricted (no template, or a + * base/admin role), all roles are available so existing configurations are not disturbed. + * + * Backend enforcement in ExecJitAdmin is authoritative; this hook only shapes the picker options. + */ +export const useJitAllowedRoles = () => { + const query = ApiGetCall({ + url: "/api/ListJITAllowedRoles", + queryKey: "JITAllowedRoles", + }); + + const restricted = query.data?.Restricted === true; + const allowedRoleIds = query.data?.AllowedRoleIds || []; + + // Filter a list of role catalog entries ({ Name, ObjectId }) down to the allowed set. + const filterRoles = (roles = []) => + restricted ? roles.filter((role) => allowedRoleIds.includes(role.ObjectId)) : roles; + + return { + isLoading: query.isLoading, + isSuccess: query.isSuccess, + restricted, + allowedRoleIds, + filterRoles, + }; +}; diff --git a/src/hooks/use-license-currency.js b/src/hooks/use-license-currency.js new file mode 100644 index 000000000000..3c1cda973b66 --- /dev/null +++ b/src/hooks/use-license-currency.js @@ -0,0 +1,28 @@ +import { useCallback, useState } from 'react' + +// Remembers the currency chosen on the license report tabs (Pricing + Optimization) so switching +// it on one tab carries to the other. Persisted in localStorage; falls back gracefully when it is +// unavailable (private windows, blocked site data). +const STORAGE_KEY = 'licenseReportCurrency' + +export const useLicenseCurrency = (fallback = 'USD') => { + const [currency, setCurrencyState] = useState(() => { + if (typeof window === 'undefined') return fallback + try { + return window.localStorage.getItem(STORAGE_KEY) || fallback + } catch { + return fallback + } + }) + + const setCurrency = useCallback((next) => { + setCurrencyState(next) + try { + window.localStorage.setItem(STORAGE_KEY, next) + } catch { + /* ignore - non-persistent is fine */ + } + }, []) + + return [currency, setCurrency] +} diff --git a/src/hooks/use-permissions.js b/src/hooks/use-permissions.js index 3fa9b6185707..44a76a0dc5fb 100644 --- a/src/hooks/use-permissions.js +++ b/src/hooks/use-permissions.js @@ -1,96 +1,103 @@ -import { useCallback } from "react"; -import { ApiGetCall } from "../api/ApiCall"; -import { hasAccess, hasPermission, hasRole } from "../utils/permissions"; - -/** - * Hook for checking user permissions and roles - * Integrates with the existing CIPP authentication system - */ -export const usePermissions = () => { - const currentRole = ApiGetCall({ - url: "/api/me", - queryKey: "authmecipp", - }); - - const userRoles = currentRole.data?.clientPrincipal?.userRoles || []; - const userPermissions = currentRole.data?.permissions || []; - const isLoading = currentRole.isLoading; - const isAuthenticated = currentRole.isSuccess && userRoles.length > 0; - - /** - * Check if user has specific permissions - * @param {string[]} requiredPermissions - Array of required permissions (supports wildcards) - * @returns {boolean} - True if user has at least one of the required permissions - */ - const checkPermissions = useCallback( - (requiredPermissions) => { - if (!isAuthenticated) return false; - return hasPermission(userPermissions, requiredPermissions); - }, - [userPermissions, isAuthenticated] - ); - - /** - * Check if user has specific roles - * @param {string[]} requiredRoles - Array of required roles - * @returns {boolean} - True if user has at least one of the required roles - */ - const checkRoles = useCallback( - (requiredRoles) => { - if (!isAuthenticated) return false; - return hasRole(userRoles, requiredRoles); - }, - [userRoles, isAuthenticated] - ); - - /** - * Check if user has access based on both permissions and roles - * @param {Object} config - Configuration object - * @param {string[]} config.requiredPermissions - Array of required permissions - * @param {string[]} config.requiredRoles - Array of required roles - * @returns {boolean} - True if user has access - */ - const checkAccess = useCallback( - (config = {}) => { - if (!isAuthenticated) return false; - - const { requiredPermissions = [], requiredRoles = [] } = config; - - return hasAccess({ - userPermissions, - userRoles, - requiredPermissions, - requiredRoles, - }); - }, - [userPermissions, userRoles, isAuthenticated] - ); - - return { - userPermissions, - userRoles, - isLoading, - isAuthenticated, - checkPermissions, - checkRoles, - checkAccess, - }; -}; - -/** - * Hook specifically for checking permissions with a simpler API - * @param {string[]} requiredPermissions - Array of required permissions - * @param {string[]} requiredRoles - Array of required roles - * @returns {Object} - Object containing hasAccess boolean and loading state - */ -export const useHasPermission = (requiredPermissions = [], requiredRoles = []) => { - const { checkAccess, isLoading, isAuthenticated } = usePermissions(); - - const hasAccess = checkAccess({ requiredPermissions, requiredRoles }); - - return { - hasAccess, - isLoading, - isAuthenticated, - }; -}; +import { useCallback } from "react"; +import { ApiGetCall } from "../api/ApiCall"; +import { hasAccess, hasPermission, hasRole } from "../utils/permissions"; + +/** + * Hook for checking user permissions and roles + * Integrates with the existing CIPP authentication system + */ +export const usePermissions = () => { + const currentRole = ApiGetCall({ + url: "/api/me", + queryKey: "authmecipp", + }); + + const userRoles = currentRole.data?.clientPrincipal?.userRoles || []; + const userPermissions = currentRole.data?.permissions || []; + const isLoading = currentRole.isLoading; + const isAuthenticated = currentRole.isSuccess && userRoles.length > 0; + // CyberDrain-hosted instance: some infrastructure (custom domains on the shared plan) is + // managed in the management portal rather than from CIPP itself. + const isHosted = Boolean(currentRole.data?.hosted); + // CIPP-NG (container web app on an App Service plan) versus a legacy function app + static web app. + const isNg = Boolean(currentRole.data?.ng); + + /** + * Check if user has specific permissions + * @param {string[]} requiredPermissions - Array of required permissions (supports wildcards) + * @returns {boolean} - True if user has at least one of the required permissions + */ + const checkPermissions = useCallback( + (requiredPermissions) => { + if (!isAuthenticated) return false; + return hasPermission(userPermissions, requiredPermissions); + }, + [userPermissions, isAuthenticated] + ); + + /** + * Check if user has specific roles + * @param {string[]} requiredRoles - Array of required roles + * @returns {boolean} - True if user has at least one of the required roles + */ + const checkRoles = useCallback( + (requiredRoles) => { + if (!isAuthenticated) return false; + return hasRole(userRoles, requiredRoles); + }, + [userRoles, isAuthenticated] + ); + + /** + * Check if user has access based on both permissions and roles + * @param {Object} config - Configuration object + * @param {string[]} config.requiredPermissions - Array of required permissions + * @param {string[]} config.requiredRoles - Array of required roles + * @returns {boolean} - True if user has access + */ + const checkAccess = useCallback( + (config = {}) => { + if (!isAuthenticated) return false; + + const { requiredPermissions = [], requiredRoles = [] } = config; + + return hasAccess({ + userPermissions, + userRoles, + requiredPermissions, + requiredRoles, + }); + }, + [userPermissions, userRoles, isAuthenticated] + ); + + return { + userPermissions, + userRoles, + isLoading, + isAuthenticated, + isHosted, + isNg, + checkPermissions, + checkRoles, + checkAccess, + }; +}; + +/** + * Hook specifically for checking permissions with a simpler API + * @param {string[]} requiredPermissions - Array of required permissions + * @param {string[]} requiredRoles - Array of required roles + * @returns {Object} - Object containing hasAccess boolean and loading state + */ +export const useHasPermission = (requiredPermissions = [], requiredRoles = []) => { + const { checkAccess, isLoading, isAuthenticated } = usePermissions(); + + const hasAccess = checkAccess({ requiredPermissions, requiredRoles }); + + return { + hasAccess, + isLoading, + isAuthenticated, + }; +}; diff --git a/src/hooks/use-sharepoint-browser-recents.js b/src/hooks/use-sharepoint-browser-recents.js new file mode 100644 index 000000000000..913cd3a61a65 --- /dev/null +++ b/src/hooks/use-sharepoint-browser-recents.js @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from 'react' +import { + SHAREPOINT_BROWSER_RECENTS_CHANGE_EVENT, + addRecentSharePointSite, + getRecentSharePointSites, +} from '../utils/sharepoint-browser-recents' + +/** + * Browser-local recent SharePoint sites for the site-browser switcher (localStorage). + * @param {string} tenantFilter + */ +export function useSharePointBrowserRecents(tenantFilter) { + const [recent, setRecent] = useState([]) + + const refresh = useCallback(() => { + setRecent(getRecentSharePointSites(tenantFilter)) + }, [tenantFilter]) + + useEffect(() => { + refresh() + + const onStorage = (event) => { + if ( + !event.key || + (typeof event.key === 'string' && event.key.startsWith('cipp:sp-browser:recents:')) + ) { + refresh() + } + } + const onCustom = () => refresh() + + window.addEventListener('storage', onStorage) + window.addEventListener(SHAREPOINT_BROWSER_RECENTS_CHANGE_EVENT, onCustom) + return () => { + window.removeEventListener('storage', onStorage) + window.removeEventListener(SHAREPOINT_BROWSER_RECENTS_CHANGE_EVENT, onCustom) + } + }, [refresh]) + + const trackRecent = useCallback( + (site) => { + addRecentSharePointSite(tenantFilter, site) + refresh() + }, + [tenantFilter, refresh] + ) + + return { recent, trackRecent, refresh } +} diff --git a/src/icons/iconly/bulk/azure.js b/src/icons/iconly/bulk/azure.jsx similarity index 100% rename from src/icons/iconly/bulk/azure.js rename to src/icons/iconly/bulk/azure.jsx diff --git a/src/icons/iconly/bulk/bag.js b/src/icons/iconly/bulk/bag.jsx similarity index 100% rename from src/icons/iconly/bulk/bag.js rename to src/icons/iconly/bulk/bag.jsx diff --git a/src/icons/iconly/bulk/buy.js b/src/icons/iconly/bulk/buy.jsx similarity index 100% rename from src/icons/iconly/bulk/buy.js rename to src/icons/iconly/bulk/buy.jsx diff --git a/src/icons/iconly/bulk/calendar.js b/src/icons/iconly/bulk/calendar.jsx similarity index 100% rename from src/icons/iconly/bulk/calendar.js rename to src/icons/iconly/bulk/calendar.jsx diff --git a/src/icons/iconly/bulk/category.js b/src/icons/iconly/bulk/category.jsx similarity index 100% rename from src/icons/iconly/bulk/category.js rename to src/icons/iconly/bulk/category.jsx diff --git a/src/icons/iconly/bulk/chart.js b/src/icons/iconly/bulk/chart.jsx similarity index 100% rename from src/icons/iconly/bulk/chart.js rename to src/icons/iconly/bulk/chart.jsx diff --git a/src/icons/iconly/bulk/defender.js b/src/icons/iconly/bulk/defender.jsx similarity index 100% rename from src/icons/iconly/bulk/defender.js rename to src/icons/iconly/bulk/defender.jsx diff --git a/src/icons/iconly/bulk/document.js b/src/icons/iconly/bulk/document.jsx similarity index 100% rename from src/icons/iconly/bulk/document.js rename to src/icons/iconly/bulk/document.jsx diff --git a/src/icons/iconly/bulk/exchange.js b/src/icons/iconly/bulk/exchange.jsx similarity index 100% rename from src/icons/iconly/bulk/exchange.js rename to src/icons/iconly/bulk/exchange.jsx diff --git a/src/icons/iconly/bulk/graph.js b/src/icons/iconly/bulk/graph.jsx similarity index 100% rename from src/icons/iconly/bulk/graph.js rename to src/icons/iconly/bulk/graph.jsx diff --git a/src/icons/iconly/bulk/intune.js b/src/icons/iconly/bulk/intune.jsx similarity index 100% rename from src/icons/iconly/bulk/intune.js rename to src/icons/iconly/bulk/intune.jsx diff --git a/src/icons/iconly/bulk/microsoft.js b/src/icons/iconly/bulk/microsoft.jsx similarity index 100% rename from src/icons/iconly/bulk/microsoft.js rename to src/icons/iconly/bulk/microsoft.jsx diff --git a/src/icons/iconly/bulk/settings.js b/src/icons/iconly/bulk/settings.jsx similarity index 100% rename from src/icons/iconly/bulk/settings.js rename to src/icons/iconly/bulk/settings.jsx diff --git a/src/icons/iconly/bulk/sharepoint.js b/src/icons/iconly/bulk/sharepoint.jsx similarity index 100% rename from src/icons/iconly/bulk/sharepoint.js rename to src/icons/iconly/bulk/sharepoint.jsx diff --git a/src/icons/iconly/bulk/show.js b/src/icons/iconly/bulk/show.jsx similarity index 100% rename from src/icons/iconly/bulk/show.js rename to src/icons/iconly/bulk/show.jsx diff --git a/src/icons/iconly/bulk/teams.js b/src/icons/iconly/bulk/teams.jsx similarity index 100% rename from src/icons/iconly/bulk/teams.js rename to src/icons/iconly/bulk/teams.jsx diff --git a/src/icons/iconly/bulk/three-user.js b/src/icons/iconly/bulk/three-user.jsx similarity index 100% rename from src/icons/iconly/bulk/three-user.js rename to src/icons/iconly/bulk/three-user.jsx diff --git a/src/icons/iconly/bulk/ticket.js b/src/icons/iconly/bulk/ticket.jsx similarity index 100% rename from src/icons/iconly/bulk/ticket.js rename to src/icons/iconly/bulk/ticket.jsx diff --git a/src/icons/iconly/bulk/two-user.js b/src/icons/iconly/bulk/two-user.jsx similarity index 100% rename from src/icons/iconly/bulk/two-user.js rename to src/icons/iconly/bulk/two-user.jsx diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 141824e0c90c..000000000000 --- a/src/index.js +++ /dev/null @@ -1,17 +0,0 @@ - -import { Layout as DashboardLayout } from "./layouts/index.js"; - -const Page = () => { - const pageTitle = "Dashboard"; - - return ( -
    -

    {pageTitle}

    -

    This is a placeholder page for the dashboard section.

    -
    - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; diff --git a/src/index.jsx b/src/index.jsx new file mode 100644 index 000000000000..cd00939caff7 --- /dev/null +++ b/src/index.jsx @@ -0,0 +1,17 @@ + +import { Layout as DashboardLayout } from "./layouts/index"; + +const Page = () => { + const pageTitle = "Dashboard"; + + return ( +
    +

    {pageTitle}

    +

    This is a placeholder page for the dashboard section.

    +
    + ); +}; + +Page.getLayout = (page) => {page}; + +export default Page; diff --git a/src/layouts/HeaderedTabbedLayout.jsx b/src/layouts/HeaderedTabbedLayout.jsx index 94ef13bcfa42..9357853041e1 100644 --- a/src/layouts/HeaderedTabbedLayout.jsx +++ b/src/layouts/HeaderedTabbedLayout.jsx @@ -2,7 +2,6 @@ import { useCallback, useMemo } from "react"; import { usePathname } from "next/navigation"; import { useRouter } from "next/router"; import PropTypes from "prop-types"; -import ArrowLeftIcon from "@heroicons/react/24/outline/ArrowLeftIcon"; import { Box, Container, @@ -15,7 +14,7 @@ import { Typography, } from "@mui/material"; import { ActionsMenu } from "../components/actions-menu"; -import { getIconByName } from "../utils/icon-registry"; +import { CippIcons, getIconByName } from "../utils/icon-registry" import { useIsMobileLayout } from "../hooks/use-breakpoint"; import { useActionsDispatch } from "../hooks/use-actions-dispatch"; import { TabNavigationContext, useTabNavigationValue } from "./tab-navigation-context"; @@ -129,12 +128,15 @@ export const HeaderedTabbedLayout = (props) => { // title above them. Gap applies to both axes, so the row gap is set separately or the // stacked pairs end up as far apart vertically as they are horizontally. + sx={{ + alignItems: "center", + flexWrap: "wrap", + columnGap: 2, + rowGap: 0.5, + minWidth: 0 + }}> {/* minWidth: 0 down the whole chain, and flexShrink: 0 on the icon. A copy-chip already carries MUI's ellipsis and maxWidth: 100%, but flex items default to min-width: auto, so every ancestor grew to fit instead of letting it truncate — @@ -148,19 +150,24 @@ export const HeaderedTabbedLayout = (props) => { ) : ( + sx={{ + alignItems: "center", + minWidth: 0, + maxWidth: "100%" + }}> {item.icon} + sx={{ + color: "text.secondary", + minWidth: 0, + "& .MuiChip-root": { maxWidth: "100%" } + }}> {item.text} @@ -186,21 +193,23 @@ export const HeaderedTabbedLayout = (props) => { + sx={{ + alignItems: isMobile ? "center" : "flex-start", + justifyContent: "space-between" + }}> {/* minWidth: 0 so a long tenant/entity name truncates in the space the picker leaves rather than pushing it off the right edge of the row. Scoped to the picker's own breakpoint — above md this is unchanged. */} + sx={{ + alignItems: "center", + justifyContent: "space-between" + }}> {/* A name-shaped skeleton, not the word "Loading...": the header is the entity's identity, and a text placeholder reads as a title. titleControl lets a page swap the text for an interactive control @@ -313,7 +322,8 @@ HeaderedTabbedLayout.propTypes = { subtitle: PropTypes.arrayOf( PropTypes.shape({ icon: PropTypes.node.isRequired, - text: PropTypes.string.isRequired, + // Often a copy-chip (or other node), not plain text — see users/groups headers. + text: PropTypes.node.isRequired, }) ), actions: PropTypes.arrayOf( diff --git a/src/layouts/TabbedLayout.jsx b/src/layouts/TabbedLayout.jsx index 2ac692e10ce9..8010482611e0 100644 --- a/src/layouts/TabbedLayout.jsx +++ b/src/layouts/TabbedLayout.jsx @@ -10,7 +10,9 @@ import { TabNavigationContext, useTabNavigationValue } from './tab-navigation-co import { CippTabPicker } from '../components/CippComponents/CippTabPicker' export const TabbedLayout = (props) => { - const { tabOptions, children } = props + // `activePath` lets a page reached from a tab (rather than being one) keep its parent tab + // highlighted; the tab itself still navigates to its own path. + const { tabOptions, children, activePath } = props const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() @@ -60,7 +62,8 @@ export const TabbedLayout = (props) => { const handleTabsChange = (event, value) => navigateToTab(value) - const currentTab = visibleTabs.find((option) => option.path === pathname) + const resolvedPath = activePath ?? pathname + const currentTab = visibleTabs.find((option) => option.path === resolvedPath) // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so // navigation collapses to a full-width picker in the slot the tab bar occupied. Always the @@ -69,7 +72,7 @@ export const TabbedLayout = (props) => { const isMobile = useIsMobileLayout() const tabNavValue = useTabNavigationValue({ tabs: visibleTabs, - currentPath: pathname, + currentPath: resolvedPath, onNavigate: navigateToTab, enabled: isMobile, }) diff --git a/src/layouts/account-popover.js b/src/layouts/account-popover.js deleted file mode 100644 index e27a3aba6e83..000000000000 --- a/src/layouts/account-popover.js +++ /dev/null @@ -1,305 +0,0 @@ -import { useCallback } from "react"; -import PropTypes from "prop-types"; -import { useRouter } from "next/navigation"; -import toast from "react-hot-toast"; -import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon"; -import ArrowRightOnRectangleIcon from "@heroicons/react/24/outline/ArrowRightOnRectangleIcon"; -import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; -import MagnifyingGlassIcon from "@heroicons/react/24/outline/MagnifyingGlassIcon"; -import MoonIcon from "@heroicons/react/24/outline/MoonIcon"; -import SunIcon from "@heroicons/react/24/outline/SunIcon"; -import { - Avatar, - Box, - CircularProgress, - List, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, - Popover, - Stack, - SvgIcon, - Typography, - useMediaQuery, -} from "@mui/material"; -import { usePopover } from "../hooks/use-popover"; -import { useIsMobileLayout } from "../hooks/use-breakpoint"; -import { useDialog } from "../hooks/use-dialog"; -import { paths } from "../paths"; -import { ApiGetCall } from "../api/ApiCall"; -import { CippApiDialog } from "../components/CippComponents/CippApiDialog"; -import { CogIcon, DocumentTextIcon, LifebuoyIcon, TrashIcon } from "@heroicons/react/24/outline"; -import ArrowTopRightOnSquareIcon from "@heroicons/react/24/outline/ArrowTopRightOnSquareIcon"; -import { useReleaseNotes } from "../contexts/release-notes-context"; -import { useQueryClient } from "@tanstack/react-query"; -import { usePathname } from "next/navigation"; -import { Divider } from "@mui/material"; -import { getHelpLinks, clearCippCache } from "../utils/help-links"; - -export const AccountPopover = (props) => { - const { - direction = "ltr", - language = "en", - onThemeSwitch, - onOpenSearch, - paletteMode = "light", - ...other - } = props; - const router = useRouter(); - const pathname = usePathname(); - const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); - const navCollapsed = useIsMobileLayout(); - const popover = usePopover(); - const queryClient = useQueryClient(); - const { openReleaseNotes } = useReleaseNotes(); - const orgData = ApiGetCall({ - url: "/api/me", - queryKey: "authmecipp", - }); - - const userDetails = orgData.data?.clientPrincipal?.userDetails; - - // Cache user photo with user-specific key - const userPhoto = ApiGetCall({ - url: "/api/ListUserPhoto", - data: { UserID: userDetails }, - queryKey: `userPhoto-${userDetails}`, - waiting: !!userDetails, - staleTime: Infinity, - responseType: "blob", - convertToDataUrl: true, - }); - - // Re-checks Entra group membership server-side, then refetches /api/me so a role granted - // through a just-activated PIM group applies without waiting out the role cache. Runs - // through the standard confirm dialog, which also renders the API result. - const refreshAccessDialog = useDialog(); - - const handleLogout = useCallback(async () => { - try { - popover.handleClose(); - // delete query cache and persisted data - queryClient.clear(); - - router.push("/.auth/logout?post_logout_redirect_uri=" + encodeURIComponent(paths.index)); - } catch (err) { - console.error(err); - console.log(orgData); - toast.error("Something went wrong"); - } - }, [router, popover]); - - const defaultAvatar = ( - - {userDetails?.[0]?.toUpperCase() || ""} - - ); - - return ( - <> - - {defaultAvatar} - <> - {!mdDown && ( - <> - - - {orgData.data?.clientPrincipal?.userDetails?.split("@")?.[1]} - - - {orgData.data?.clientPrincipal?.userDetails ?? "Not logged in"} - - - {orgData.data?.clientPrincipal?.userDetails && ( - <> - {orgData?.isFetching ? ( - - ) : ( - - - - )} - - )} - - )} - - - {orgData.data?.clientPrincipal?.userDetails && ( - - )} - {orgData.data?.clientPrincipal?.userDetails && ( - - - {/* Pairs with the trigger above: the identity is either beside the avatar or here. */} - {mdDown && ( - - - - )} - {/* Home for the two bar icons top-nav drops at navCollapsed (useIsMobileLayout), - so they stay reachable wherever the bar isn't showing them. */} - {navCollapsed && ( - <> - {onOpenSearch && ( - { - popover.handleClose(); - onOpenSearch(); - }} - > - - - - - - - - )} - { popover.handleClose(); onThemeSwitch(); }}> - - - {paletteMode === "dark" ? : } - - - - - - )} - { popover.handleClose(); router.push("/cipp/preferences"); }}> - - - - - - - - { - popover.handleClose(); - openReleaseNotes(); - }} - > - - - - - - - - {/* Mobile home for the help SpeedDial's destinations — its FAB corner belongs - to page actions there (the SpeedDial hides itself below md). */} - {mdDown && ( - <> - - {getHelpLinks(pathname ?? "").map((link) => ( - { - popover.handleClose(); - window.open(link.href, "_blank"); - }} - > - - - - - - - - - - - ))} - { - popover.handleClose(); - clearCippCache(queryClient); - }} - > - - - - - - - - - - )} - { - popover.handleClose(); - refreshAccessDialog.handleOpen(); - }} - > - - - - - - - - - - - - - - - - - - )} - - ); -}; - -AccountPopover.propTypes = { - onThemeSwitch: PropTypes.func, - paletteMode: PropTypes.oneOf(["dark", "light"]), -}; diff --git a/src/layouts/account-popover.jsx b/src/layouts/account-popover.jsx new file mode 100644 index 000000000000..3aeaf0bceab3 --- /dev/null +++ b/src/layouts/account-popover.jsx @@ -0,0 +1,306 @@ +import { useCallback } from "react"; +import { CippIcons } from "../utils/icon-registry"; +import PropTypes from "prop-types"; +import { useRouter } from "next/navigation"; +import { + Avatar, + Box, + CircularProgress, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Popover, + Stack, + SvgIcon, + Typography, + useMediaQuery, +} from "@mui/material"; +import { usePopover } from "../hooks/use-popover"; +import { useIsMobileLayout } from "../hooks/use-breakpoint"; +import { useDialog } from "../hooks/use-dialog"; +import { paths } from "../paths"; +import { ApiGetCall } from "../api/ApiCall"; +import { CippApiDialog } from "../components/CippComponents/CippApiDialog"; +import { useReleaseNotes } from "../contexts/release-notes-context"; +import { useQueryClient } from "@tanstack/react-query"; +import { usePathname } from "next/navigation"; +import { Divider } from "@mui/material"; +import { getHelpLinks, clearCippCache } from "../utils/help-links"; + +export const AccountPopover = (props) => { + const { + direction = "ltr", + language = "en", + onThemeSwitch, + onOpenSearch, + paletteMode = "light", + ...other + } = props; + const router = useRouter(); + const pathname = usePathname(); + const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const navCollapsed = useIsMobileLayout(); + const popover = usePopover(); + const queryClient = useQueryClient(); + const { openReleaseNotes } = useReleaseNotes(); + const orgData = ApiGetCall({ + url: "/api/me", + queryKey: "authmecipp", + }); + + const userDetails = orgData.data?.clientPrincipal?.userDetails; + + // Cache user photo with user-specific key + const userPhoto = ApiGetCall({ + url: "/api/ListUserPhoto", + data: { UserID: userDetails }, + queryKey: `userPhoto-${userDetails}`, + waiting: !!userDetails, + staleTime: Infinity, + responseType: "blob", + convertToDataUrl: true, + }); + + // Re-checks Entra group membership server-side, then refetches /api/me so a role granted + // through a just-activated PIM group applies without waiting out the role cache. Runs + // through the standard confirm dialog, which also renders the API result. + const refreshAccessDialog = useDialog(); + + const handleLogout = useCallback(() => { + const logoutUrl = + "/.auth/logout?post_logout_redirect_uri=" + encodeURIComponent(paths.index); + try { + popover.handleClose(); + // delete query cache and persisted data + queryClient.clear(); + + router.push(logoutUrl); + } catch (err) { + console.error(err); + // Fall back to a hard navigation so a router failure still logs the user out. + window.location.href = logoutUrl; + } + }, [router, popover, queryClient]); + + const defaultAvatar = ( + + {userDetails?.[0]?.toUpperCase() || ""} + + ); + + return ( + <> + + {defaultAvatar} + <> + {!mdDown && ( + <> + + + {orgData.data?.clientPrincipal?.userDetails?.split("@")?.[1]} + + + {orgData.data?.clientPrincipal?.userDetails ?? "Not logged in"} + + + {orgData.data?.clientPrincipal?.userDetails && ( + <> + {orgData?.isFetching ? ( + + ) : ( + + + + )} + + )} + + )} + + + {orgData.data?.clientPrincipal?.userDetails && ( + + )} + {orgData.data?.clientPrincipal?.userDetails && ( + + + {/* Pairs with the trigger above: the identity is either beside the avatar or here. */} + {mdDown && ( + + + + )} + {/* Home for the two bar icons top-nav drops at navCollapsed (useIsMobileLayout), + so they stay reachable wherever the bar isn't showing them. */} + {navCollapsed && ( + <> + {onOpenSearch && ( + { + popover.handleClose(); + onOpenSearch(); + }} + > + + + + + + + + )} + { popover.handleClose(); onThemeSwitch(); }}> + + + {paletteMode === "dark" ? : } + + + + + + )} + { popover.handleClose(); router.push("/cipp/preferences"); }}> + + + + + + + + { + popover.handleClose(); + openReleaseNotes(); + }} + > + + + + + + + + {/* Mobile home for the help SpeedDial's destinations — its FAB corner belongs + to page actions there (the SpeedDial hides itself below md). */} + {mdDown && ( + <> + + {getHelpLinks(pathname ?? "").map((link) => ( + { + popover.handleClose(); + window.open(link.href, "_blank"); + }} + > + + + + + + + + + + + ))} + { + popover.handleClose(); + clearCippCache(queryClient); + }} + > + + + + + + + + + + )} + { + popover.handleClose(); + refreshAccessDialog.handleOpen(); + }} + > + + + + + + + + + + + + + + + + + + )} + + ); +}; + +AccountPopover.propTypes = { + onThemeSwitch: PropTypes.func, + paletteMode: PropTypes.oneOf(["dark", "light"]), +}; diff --git a/src/layouts/config.js b/src/layouts/config.js deleted file mode 100644 index c54b951f3ec4..000000000000 --- a/src/layouts/config.js +++ /dev/null @@ -1,1268 +0,0 @@ -import { - BuildingOfficeIcon, - HomeIcon, - SparklesIcon, - UsersIcon, - WrenchIcon, -} from '@heroicons/react/24/outline' -import { - CloudOutlined, - HomeRepairService, - Laptop, - MailOutline, - ShieldOutlined, -} from '@mui/icons-material' -import { SvgIcon } from '@mui/material' - -export const nativeMenuItems = [ - { - title: 'Dashboard', - path: '/', - icon: ( - - - - ), - permissions: ['CIPP.Core.*'], - }, - { - title: 'Identity Management', - type: 'header', - icon: ( - - - - ), - permissions: ['Identity.*'], - items: [ - { - title: 'Administration', - permissions: ['Identity.User.*'], - items: [ - { - title: 'Users', - path: '/identity/administration/users', - permissions: ['Identity.User.*'], - }, - { - title: 'Guest Users', - path: '/identity/administration/guest-users', - permissions: ['Identity.User.*'], - }, - { - title: 'Risky Users', - path: '/identity/administration/risky-users', - permissions: ['Identity.User.*'], - }, - { - title: 'Groups', - path: '/identity/administration/groups', - permissions: ['Identity.Group.*'], - }, - { - title: 'Group Templates', - path: '/identity/administration/group-templates', - permissions: ['Identity.Group.*'], - scope: 'global', - }, - { - title: 'Devices', - path: '/identity/administration/devices', - permissions: ['Identity.Device.*'], - }, - { - title: 'Deleted Items', - path: '/identity/administration/deleted-items', - permissions: ['Identity.User.*'], - }, - { - title: 'Roles', - path: '/identity/administration/roles', - permissions: ['Identity.Role.*'], - }, - { - title: 'JIT Admin', - path: '/identity/administration/jit-admin', - permissions: ['Identity.Role.*'], - }, - { - title: 'JIT Admin Templates', - path: '/identity/administration/jit-admin-templates', - permissions: ['Identity.Role.*'], - scope: 'global', - }, - { - title: 'Vacation Mode', - path: '/identity/administration/vacation-mode', - permissions: ['Identity.User.*'], - }, - { - title: 'Offboarding Wizard', - path: '/identity/administration/offboarding-wizard', - permissions: ['Identity.User.*'], - }, - ], - }, - { - title: 'Reports', - permissions: [ - 'Identity.User.*', - 'Identity.Group.*', - 'Identity.Device.*', - 'Identity.Role.*', - 'Identity.AuditLog.*', - ], - items: [ - { - title: 'MFA Report', - path: '/identity/reports/mfa-report', - permissions: ['Identity.User.*'], - }, - { - title: 'Inactive Users', - path: '/identity/reports/inactive-users-report', - permissions: ['Identity.User.*'], - }, - { - title: 'Sign-in Report', - path: '/identity/reports/signin-report', - permissions: ['Identity.User.*'], - }, - { - title: 'Microsoft Entra Connect Report', - path: '/identity/reports/azure-ad-connect-report', - permissions: ['Identity.User.*'], - }, - { - title: 'Risk Detections', - path: '/identity/reports/risk-detections', - permissions: ['Identity.User.*'], - }, - ], - }, - ], - }, - { - title: 'Tenant Administration', - type: 'header', - icon: ( - - - - ), - permissions: [ - 'Tenant.*', - 'Identity.AuditLog.*', - 'CIPP.Backup.*', - 'Scheduler.Billing.*', - ], - items: [ - { - title: 'Administration', - permissions: ['Tenant.Administration.*'], - items: [ - { - title: 'Tenants', - path: '/tenant/administration/tenants', - permissions: ['Tenant.Administration.*'], - scope: 'global', - }, - { - title: 'Alert Configuration', - path: '/tenant/administration/alert-configuration', - permissions: ['Tenant.Alert.*'], - scope: 'global', - }, - { - title: 'Audit Logs', - path: '/tenant/administration/audit-logs', - permissions: ['Identity.AuditLog.*'], - }, - { - title: 'Applications', - path: '/tenant/administration/applications/enterprise-apps', - permissions: ['Tenant.Application.*'], - }, - { - title: 'Secure Score', - path: '/tenant/administration/securescore', - permissions: ['Tenant.Administration.*'], - }, - { - title: 'App Consent Requests', - path: '/tenant/administration/app-consent-requests', - permissions: ['Tenant.Application.*'], - }, - { - title: 'Authentication Methods', - path: '/tenant/administration/authentication-methods', - permissions: ['Tenant.Config.*'], - }, - { - title: 'Partner Relationships', - path: '/tenant/administration/partner-relationships', - permissions: ['Tenant.Relationship.*'], - }, - { - title: 'Domains', - path: '/tenant/administration/domains', - permissions: ['Tenant.Administration.*'], - }, - ], - }, - { - title: 'GDAP Management', - path: '/tenant/gdap-management', - permissions: ['Tenant.Relationship.*'], - scope: 'global', - }, - // Flag-gated swap: the Baselines feature flag lists this path in its Pages - // (hidden while the flag is off) and the classic Standards/Drift paths in - // HidesPages (hidden while it is on) - the two never show together. - { - title: 'Baselines', - path: '/tenant/baselines', - permissions: ['Tenant.Baselines.*'], - scope: 'global', - }, - { - title: 'Domains Analyser', - path: '/tenant/standards/domains-analyser', - permissions: ['Tenant.DomainAnalyser.*'], - scope: 'global', - }, - { - title: 'Standards & Drift', - path: '/tenant/standards/alignment', - permissions: ['Tenant.Standards.*'], - scope: 'global', - }, - { - title: 'Best Practice Analyser', - path: '/tenant/standards/bpa-report', - permissions: ['Tenant.BestPracticeAnalyser.*'], - scope: 'global', - }, - { - title: 'Conditional Access', - permissions: ['Tenant.ConditionalAccess.*'], - items: [ - { - title: 'CA Policies', - path: '/tenant/conditional/list-policies', - permissions: ['Tenant.ConditionalAccess.*'], - }, - { - title: 'CA Vacation Mode', - path: '/tenant/conditional/deploy-vacation', - permissions: ['Tenant.ConditionalAccess.*'], - }, - { - title: 'CA Templates', - path: '/tenant/conditional/list-template', - permissions: ['Tenant.ConditionalAccess.*'], - scope: 'global', - }, - { - title: 'Named Locations', - path: '/tenant/conditional/list-named-locations', - permissions: ['Tenant.ConditionalAccess.*'], - }, - ], - }, - { - title: 'Reports', - permissions: [ - 'Tenant.Administration.*', - 'Scheduler.Billing.*', - 'Tenant.Application.*', - ], - items: [ - { - title: 'Licence Report', - path: '/tenant/reports/list-licenses', - permissions: ['Tenant.Administration.*'], - }, - { - title: 'Sherweb Licence Report', - path: '/tenant/reports/list-csp-licenses', - permissions: ['Tenant.Directory.*'], - }, - { - title: 'Consented Applications', - path: '/tenant/reports/application-consent', - permissions: ['Tenant.Application.*'], - }, - { - title: 'Graph / Office Reports', - path: '/tenant/reports/graph-office-reports', - permissions: ['Tenant.Reports.*'], - }, - { - title: 'Custom Test Report', - path: '/tenant/reports/custom-test-report', - permissions: ['Tenant.Reports.*'], - }, - ], - }, - { - title: 'Manage Tenant', - path: '/tenant/manage/edit', - permissions: ['Tenant.Administration.*'], - }, - ], - }, - { - title: 'Security & Compliance', - type: 'header', - icon: ( - - - - ), - permissions: [ - 'Security.Incident.*', - 'Security.Alert.*', - 'Tenant.DeviceCompliance.*', - 'Security.SafeLinksPolicy.*', - 'Security.DlpCompliancePolicy.*', - 'Security.RetentionCompliancePolicy.*', - 'Security.SensitivityLabel.*', - 'Security.SensitiveInfoType.*', - ], - items: [ - { - title: 'Incidents & Alerts', - permissions: ['Security.Incident.*'], - items: [ - { - title: 'Incidents', - path: '/security/incidents/list-incidents', - permissions: ['Security.Incident.*'], - }, - { - title: 'Alerts', - path: '/security/incidents/list-alerts', - permissions: ['Security.Alert.*'], - }, - { - title: 'MDO Alerts', - path: '/security/incidents/list-mdo-alerts', - permissions: ['Security.Alert.*'], - }, - { - title: 'Check Alerts', - path: '/security/incidents/list-check-alerts', - permissions: ['Security.Alert.*'], - }, - ], - }, - { - title: 'Defender', - permissions: ['Security.Alert.*'], - items: [ - { - title: 'Defender Status', - path: '/security/defender/list-defender', - permissions: ['Security.Alert.*'], - }, - { - title: 'Defender Deployment', - path: '/security/defender/deployment', - permissions: ['Security.Alert.*'], - }, - { - title: 'Vulnerabilities', - path: '/security/defender/list-defender-tvm', - permissions: ['Security.Alert.*'], - }, - { - title: 'CVE Management', - path: '/security/defender/defender-cve-exceptions', - permissions: ['Security.Alert.*'], - }, - ], - }, - { - title: 'Reports', - permissions: ['Tenant.DeviceCompliance.*', 'Security.Defender.*'], - items: [ - { - title: 'Device Compliance', - path: '/security/reports/list-device-compliance', - permissions: ['Tenant.DeviceCompliance.*'], - }, - { - title: 'MDE Onboarding', - path: '/security/reports/mde-onboarding', - permissions: ['Security.Defender.*'], - }, - { - title: 'Vulnerability Report', - path: '/security/reports/cve-report', - permissions: ['Security.Defender.*'], - }, - ], - }, - { - title: 'Safe Links', - permissions: ['Security.SafeLinksPolicy.*'], - items: [ - { - title: 'Safe Links Policies', - path: '/security/safelinks/safelinks', - permissions: ['Security.SafeLinksPolicy.*'], - }, - { - title: 'Safe Links Templates', - path: '/security/safelinks/safelinks-template', - permissions: ['Security.SafeLinksPolicy.*'], - scope: 'global', - }, - ], - }, - { - title: 'Purview Compliance', - permissions: [ - 'Security.DlpCompliancePolicy.*', - 'Security.RetentionCompliancePolicy.*', - 'Security.SensitivityLabel.*', - 'Security.SensitiveInfoType.*', - ], - items: [ - { - title: 'DLP Policies', - path: '/security/compliance/dlp', - permissions: ['Security.DlpCompliancePolicy.*'], - }, - { - title: 'DLP Policy Templates', - path: '/security/compliance/dlp-templates', - permissions: ['Security.DlpCompliancePolicy.*'], - scope: 'global', - }, - { - title: 'Retention Policies', - path: '/security/compliance/retention', - permissions: ['Security.RetentionCompliancePolicy.*'], - }, - { - title: 'Retention Policy Templates', - path: '/security/compliance/retention-templates', - permissions: ['Security.RetentionCompliancePolicy.*'], - scope: 'global', - }, - { - title: 'Sensitivity Labels', - path: '/security/compliance/labels', - permissions: ['Security.SensitivityLabel.*'], - }, - { - title: 'Sensitivity Label Templates', - path: '/security/compliance/labels-templates', - permissions: ['Security.SensitivityLabel.*'], - scope: 'global', - }, - { - title: 'Sensitive Information Types', - path: '/security/compliance/sit', - permissions: ['Security.SensitiveInfoType.*'], - }, - { - title: 'Sensitive Info Type Templates', - path: '/security/compliance/sit-templates', - permissions: ['Security.SensitiveInfoType.*'], - scope: 'global', - }, - ], - }, - ], - }, - { - title: 'Copilot & AI', - type: 'header', - icon: ( - - - - ), - permissions: ['Tenant.Standards.*'], - items: [ - { - title: 'Shadow AI Discovery', - path: '/copilot/shadow-ai', - permissions: ['Tenant.Standards.*'], - }, - { - title: 'Copilot Settings', - path: '/copilot/settings', - permissions: ['Tenant.Standards.*'], - }, - { - title: 'Agent365', - permissions: ['Tenant.Standards.*'], - items: [ - { - title: 'Packages', - path: '/copilot/agent365/packages', - permissions: ['Tenant.Standards.*'], - }, - ], - }, - { - title: 'Reports', - permissions: ['Tenant.Standards.*'], - items: [ - { - title: 'Copilot Adoption', - path: '/copilot/reports/copilot-adoption', - permissions: ['Tenant.Standards.*'], - }, - { - title: 'Copilot Usage Trend', - path: '/copilot/reports/copilot-trend', - permissions: ['Tenant.Standards.*'], - }, - { - title: 'Copilot User Activity', - path: '/copilot/reports/copilot-usage', - permissions: ['Tenant.Standards.*'], - }, - ], - }, - ], - }, - { - title: 'Intune', - type: 'header', - icon: ( - - - - ), - permissions: [ - 'Endpoint.Application.*', - 'Endpoint.Autopilot.*', - 'Endpoint.MEM.*', - 'Endpoint.Device.*', - 'Endpoint.Device.Read', - ], - items: [ - { - title: 'Applications', - permissions: ['Endpoint.Application.*'], - items: [ - { - title: 'Applications', - path: '/endpoint/applications/list', - permissions: ['Endpoint.Application.*'], - }, - { - title: 'Application Queue', - path: '/endpoint/applications/queue', - permissions: ['Endpoint.Application.*'], - }, - { - title: 'Application Templates', - path: '/endpoint/applications/templates', - permissions: ['Endpoint.Application.*'], - scope: 'global', - }, - ], - }, - { - title: 'Autopilot & Enrollment', - permissions: ['Endpoint.Autopilot.*'], - items: [ - { - title: 'Autopilot Devices', - path: '/endpoint/autopilot/list-devices', - permissions: ['Endpoint.Autopilot.*'], - }, - { - title: 'Add Autopilot Device', - path: '/endpoint/autopilot/add-device', - permissions: ['Endpoint.Autopilot.*'], - }, - { - title: 'Enrollment Profiles', - path: '/endpoint/autopilot/enrollment-profiles', - permissions: ['Endpoint.Autopilot.*'], - }, - { - title: 'Status Pages', - path: '/endpoint/autopilot/list-status-pages', - permissions: ['Endpoint.Autopilot.*'], - }, - ], - }, - { - title: 'Device Management', - permissions: ['Endpoint.MEM.*', 'Endpoint.Device.*'], - items: [ - { - title: 'Devices', - path: '/endpoint/MEM/devices', - permissions: ['Endpoint.Device.*'], - }, - { - title: 'BitLocker Key Search', - path: '/endpoint/MEM/bitlocker-search', - permissions: ['Endpoint.Device.*'], - }, - { - title: 'Configuration Policies', - path: '/endpoint/MEM/list-policies', - permissions: ['Endpoint.MEM.*'], - }, - { - title: 'Compliance Policies', - path: '/endpoint/MEM/list-compliance-policies', - permissions: ['Endpoint.MEM.*'], - }, - { - title: 'App Policies', - path: '/endpoint/MEM/list-appprotection-policies', - permissions: ['Endpoint.MEM.*'], - }, - { - title: 'Policy Templates', - path: '/endpoint/MEM/list-templates', - permissions: ['Endpoint.MEM.*'], - scope: 'global', - }, - { - title: 'Reusable Settings', - path: '/endpoint/MEM/reusable-settings', - permissions: ['Endpoint.MEM.*'], - }, - { - title: 'Reusable Settings Templates', - path: '/endpoint/MEM/reusable-settings-templates', - permissions: ['Endpoint.MEM.*'], - scope: 'global', - }, - { - title: 'Assignment Filters', - path: '/endpoint/MEM/assignment-filters', - permissions: ['Endpoint.MEM.*'], - }, - { - title: 'Assignment Filter Templates', - path: '/endpoint/MEM/assignment-filter-templates', - permissions: ['Endpoint.MEM.*'], - scope: 'global', - }, - { - title: 'Scripts', - path: '/endpoint/MEM/list-scripts', - permissions: ['Endpoint.MEM.*'], - }, - { - title: 'MAA Requests', - path: '/endpoint/MEM/approval-requests', - permissions: ['Endpoint.MEM.*'], - }, - ], - }, - { - title: 'Reports', - permissions: [ - 'Endpoint.Device.*', - 'Endpoint.Autopilot.*', - 'Endpoint.MEM.*', - ], - items: [ - { - title: 'Analytics Device Score', - path: '/endpoint/reports/analyticsdevicescore', - permissions: ['Endpoint.Device.*'], - }, - { - title: 'Work from anywhere', - path: '/endpoint/reports/workfromanywhere', - permissions: ['Endpoint.Device.*'], - }, - { - title: 'Autopilot Deployments', - path: '/endpoint/reports/autopilot-deployment', - permissions: ['Endpoint.Autopilot.*'], - }, - { - title: 'Discovered Apps', - path: '/endpoint/reports/detected-apps', - permissions: ['Endpoint.MEM.*'], - }, - ], - }, - ], - }, - { - title: 'Teams & SharePoint', - type: 'header', - icon: ( - - - - ), - permissions: [ - 'Sharepoint.Site.*', - 'Sharepoint.Admin.*', - 'Teams.Group.*', - 'Teams.Activity.*', - 'Teams.Voice.*', - ], - items: [ - { - title: 'OneDrive', - path: '/teams-share/onedrive', - permissions: ['Sharepoint.Site.*'], - }, - { - title: 'SharePoint', - path: '/teams-share/sharepoint', - permissions: ['Sharepoint.Admin.*'], - }, - { - title: 'SharePoint Templates', - path: '/teams-share/sharepoint-templates', - permissions: ['Sharepoint.Admin.*'], - }, - { - title: 'Deleted Sites', - path: '/teams-share/deleted-sites', - permissions: ['Sharepoint.Admin.*'], - }, - { - title: 'Sharing Report', - path: '/teams-share/sharing-report', - permissions: ['Sharepoint.Site.*'], - }, - { - title: 'Permissions Report', - path: '/teams-share/permissions-report', - permissions: ['Sharepoint.Site.*'], - }, - { - title: 'External Users', - path: '/teams-share/external-users', - permissions: ['Sharepoint.Site.*'], - }, - { - title: 'Teams', - permissions: ['Teams.Group.*'], - items: [ - { - title: 'Teams', - path: '/teams-share/teams/list-team', - permissions: ['Teams.Group.*'], - }, - { - title: 'Teams Activity', - path: '/teams-share/teams/teams-activity', - permissions: ['Teams.Activity.*'], - }, - { - title: 'Business Voice', - path: '/teams-share/teams/business-voice', - permissions: ['Teams.Voice.*'], - }, - ], - }, - ], - }, - { - title: 'Email & Exchange', - type: 'header', - icon: ( - - - - ), - permissions: [ - 'Exchange.Mailbox.*', - 'Exchange.Contact.*', - 'Exchange.SpamFilter.*', - 'Exchange.TransportRule.*', - 'Exchange.Connector.*', - 'Exchange.ConnectionFilter.*', - 'Exchange.Equipment.*', - 'Exchange.Room.*', - 'Exchange.SafeLinks.*', - 'Exchange.Group.*', - 'Exchange.RetentionPolicies.*', - ], - items: [ - { - title: 'Administration', - permissions: ['Exchange.Mailbox.*'], - items: [ - { - title: 'Mailboxes', - path: '/email/administration/mailboxes', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'HVE Accounts', - path: '/email/administration/hve-accounts', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Deleted Mailboxes', - path: '/email/administration/deleted-mailboxes', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Mailbox Rules', - path: '/email/administration/mailbox-rules', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Contacts', - path: '/email/administration/contacts', - permissions: ['Exchange.Contact.*'], - }, - { - title: 'Contact Templates', - path: '/email/administration/contacts-template', - permissions: ['Exchange.Contact.*'], - scope: 'global', - }, - { - title: 'Quarantine', - path: '/email/administration/quarantine', - permissions: ['Exchange.SpamFilter.*'], - }, - { - title: 'Restricted Users', - path: '/email/administration/restricted-users', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Tenant Allow/Block Lists', - path: '/email/administration/tenant-allow-block-lists', - permissions: ['Exchange.SpamFilter.*'], - }, - { - title: 'Allow/Block List Templates', - path: '/email/administration/tenant-allow-block-list-templates', - permissions: ['Exchange.SpamFilter.*'], - scope: 'global', - }, - { - title: 'Retention Policies & Tags', - path: '/email/administration/exchange-retention/policies', - permissions: ['Exchange.RetentionPolicies.*'], - }, - ], - }, - { - title: 'Transport', - permissions: ['Exchange.TransportRule.*', 'Exchange.Connector.*'], - items: [ - { - title: 'Transport rules', - path: '/email/transport/list-rules', - permissions: ['Exchange.TransportRule.*'], - }, - { - title: 'Transport Templates', - path: '/email/transport/list-templates', - permissions: ['Exchange.TransportRule.*'], - scope: 'global', - }, - { - title: 'Connectors', - path: '/email/transport/list-connectors', - permissions: ['Exchange.Connector.*'], - }, - { - title: 'Connector Templates', - path: '/email/transport/list-connector-templates', - permissions: ['Exchange.Connector.*'], - scope: 'global', - }, - ], - }, - { - title: 'Spamfilter', - permissions: ['Exchange.SpamFilter.*', 'Exchange.ConnectionFilter.*'], - items: [ - { - title: 'Spamfilter', - path: '/email/spamfilter/list-spamfilter', - permissions: ['Exchange.SpamFilter.*'], - }, - { - title: 'Spamfilter templates', - path: '/email/spamfilter/list-templates', - permissions: ['Exchange.SpamFilter.*'], - scope: 'global', - }, - { - title: 'Connection filter', - path: '/email/spamfilter/list-connectionfilter', - permissions: ['Exchange.ConnectionFilter.*'], - }, - { - title: 'Connection filter templates', - path: '/email/spamfilter/list-connectionfilter-templates', - permissions: ['Exchange.ConnectionFilter.*'], - scope: 'global', - }, - { - title: 'Quarantine Policies', - path: '/email/spamfilter/list-quarantine-policies', - permissions: ['Exchange.SpamFilter.*'], - }, - ], - }, - { - title: 'Resource Management', - permissions: ['Exchange.Equipment.*', 'Exchange.Room.*'], - items: [ - { - title: 'Equipment', - path: '/email/resources/management/equipment', - permissions: ['Exchange.Equipment.*'], - }, - { - title: 'Rooms', - path: '/email/resources/management/list-rooms', - permissions: ['Exchange.Room.*'], - }, - { - title: 'Room Lists', - path: '/email/resources/management/room-lists', - permissions: ['Exchange.Room.*'], - }, - ], - }, - { - title: 'Reports', - permissions: [ - 'Exchange.Mailbox.*', - 'Exchange.SpamFilter.*', - 'Exchange.SafeLinks.*', - 'Exchange.Group.*', - ], - items: [ - { - title: 'Mailbox Statistics', - path: '/email/reports/mailbox-statistics', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Mailbox Activity', - path: '/email/reports/mailbox-activity', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Mailbox Client Access Settings', - path: '/email/reports/mailbox-cas-settings', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Mailbox Permissions', - path: '/email/reports/mailbox-permissions', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Calendar Permissions', - path: '/email/reports/calendar-permissions', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Mailbox Forwarding', - path: '/email/reports/mailbox-forwarding', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Anti-Phishing Filters', - path: '/email/reports/antiphishing-filters', - permissions: ['Exchange.SpamFilter.*'], - }, - { - title: 'Malware Filters', - path: '/email/reports/malware-filters', - permissions: ['Exchange.SpamFilter.*'], - }, - { - title: 'Safe Attachments Filters', - path: '/email/reports/safeattachments-filters', - permissions: ['Exchange.SafeLinks.*'], - }, - { - title: 'Shared Mailbox with Enabled Account', - path: '/email/reports/SharedMailboxEnabledAccount', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'ActiveSync Devices', - path: '/email/reports/activesync-devices', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Global Address List', - path: '/email/reports/global-address-list', - permissions: ['Exchange.Group.*'], - }, - ], - }, - ], - }, - { - title: 'Tools', - type: 'header', - icon: ( - - - - ), - permissions: [ - 'CIPP.*', - 'Tenant.Administration.*', - 'Tenant.Application.*', - 'Tenant.DomainAnalyser.*', - 'Exchange.Mailbox.*', - 'Endpoint.MEM.*', - 'CIPP.Scheduler.*', - ], - items: [ - { - title: 'Tenant Tools', - permissions: ['Tenant.Administration.*'], - items: [ - { - title: 'Graph Explorer', - path: '/tenant/tools/graph-explorer', - permissions: ['Tenant.Administration.*'], - }, - { - title: 'Tenant Lookup', - path: '/tenant/tools/tenantlookup', - permissions: ['Tenant.Administration.*'], - scope: 'global', - }, - { - title: 'Application Approval', - path: '/tenant/tools/appapproval', - permissions: ['Tenant.Application.*'], - }, - { - title: 'Individual Domain Check', - path: '/tenant/tools/individual-domains', - permissions: ['Tenant.DomainAnalyser.*'], - scope: 'global', - }, - { - title: 'IP Database', - path: '/tenant/tools/geoiplookup', - permissions: ['CIPP.Core.*'], - scope: 'global', - }, - ], - }, - { - title: 'Email Tools', - permissions: ['Exchange.Mailbox.*'], - items: [ - { - title: 'Message Trace', - path: '/email/tools/message-trace', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Message Viewer', - path: '/email/tools/message-viewer', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Mailbox Restores', - path: '/email/tools/mailbox-restores', - permissions: ['Exchange.Mailbox.*'], - }, - { - title: 'Message Encryption', - path: '/email/tools/message-encryption', - permissions: ['Exchange.Mailbox.*'], - }, - ], - }, - { - title: 'Intune Tools', - permissions: ['Endpoint.MEM.*'], - items: [ - { - title: 'Compare Policies', - path: '/endpoint/MEM/compare-policies', - permissions: ['Endpoint.MEM.*'], - scope: 'global', - }, - ], - }, - { - title: 'Dark Web Tools', - permissions: ['CIPP.Core.*'], - items: [ - { - title: 'Tenant Breach Lookup', - path: '/tools/tenantbreachlookup', - permissions: ['CIPP.Core.*'], - scope: 'global', - }, - { - title: 'Breach Lookup', - path: '/tools/breachlookup', - permissions: ['CIPP.Core.*'], - scope: 'global', - }, - ], - }, - { - title: 'Report Builder', - path: '/tools/report-builder/generated', - roles: ['admin', 'superadmin'], - permissions: ['CIPP.Core.*'], - scope: 'global', - }, - { - title: 'Custom Tests', - path: '/tools/custom-tests', - roles: ['admin', 'superadmin'], - permissions: ['CIPP.Tests.*'], - scope: 'global', - }, - { - title: 'Template Library', - path: '/tools/templatelib', - roles: ['editor', 'admin', 'superadmin'], - permissions: ['CIPP.Core.*'], - scope: 'global', - }, - { - title: 'Catalog', - path: '/tools/community-repos', - roles: ['editor', 'admin', 'superadmin'], - permissions: ['CIPP.Core.*'], - scope: 'global', - }, - { - title: 'Scheduler', - path: '/cipp/scheduler', - roles: ['editor', 'admin', 'superadmin'], - permissions: ['CIPP.Scheduler.*'], - }, - ], - }, - { - title: 'CIPP', - type: 'header', - icon: ( - - - - ), - permissions: [ - 'CIPP.*', // Pattern matching - matches any CIPP permission - ], - items: [ - { - title: 'Application Settings', - path: '/cipp/settings', - roles: ['admin', 'superadmin'], - permissions: ['CIPP.AppSettings.*'], - scope: 'global', - }, - { - title: 'Logbook', - path: '/cipp/logs', - roles: ['editor', 'admin', 'superadmin'], - permissions: ['CIPP.Core.*'], - scope: 'global', - }, - { - title: 'Setup Wizard', - path: '/onboardingv2', - roles: ['admin', 'superadmin'], - permissions: ['CIPP.AppSettings.*'], - scope: 'global', - }, - { - title: 'Integrations', - path: '/cipp/integrations', - roles: ['admin', 'superadmin'], - permissions: ['CIPP.Extension.*'], - scope: 'global', - }, - { - title: 'Custom Data', - path: '/cipp/custom-data/directory-extensions', - roles: ['admin', 'superadmin'], - permissions: ['CIPP.AppSettings.*'], - scope: 'global', - }, - { - title: 'Advanced', - roles: ['superadmin'], - permissions: ['CIPP.SuperAdmin.*'], - items: [ - { - title: 'Super Admin', - path: '/cipp/advanced/super-admin/tenant-mode', - roles: ['superadmin'], - permissions: ['CIPP.SuperAdmin.*'], - scope: 'global', - }, - { - title: 'Container Management', - path: '/cipp/advanced/container-management/status', - roles: ['superadmin'], - permissions: ['CIPP.SuperAdmin.*'], - scope: 'global', - }, - { - // Lands on cipp-roles, not cipp-users: cipp-users is gated by the - // SuperAdminNG feature flag, and the nav filter drops any item whose - // path is in a disabled flag's Pages list — pointing here at cipp-users - // would hide the whole Authentication group (including the ungated - // SSO and SAM App pages) on non-NG instances. - title: 'Authentication', - path: '/cipp/advanced/authentication/cipp-roles', - roles: ['superadmin'], - permissions: ['CIPP.SuperAdmin.*'], - scope: 'global', - }, - { - title: 'Exchange Cmdlets', - path: '/cipp/advanced/exchange-cmdlets', - roles: ['superadmin'], - permissions: ['CIPP.SuperAdmin.*'], - scope: 'global', - }, - { - title: 'Timers', - path: '/cipp/advanced/timers', - roles: ['superadmin'], - permissions: ['CIPP.SuperAdmin.*'], - scope: 'global', - }, - { - title: 'Table Maintenance', - path: '/cipp/advanced/table-maintenance', - roles: ['superadmin'], - permissions: ['CIPP.SuperAdmin.*'], - scope: 'global', - }, - { - title: 'Diagnostics', - path: '/cipp/advanced/diagnostics', - roles: ['superadmin'], - permissions: ['CIPP.SuperAdmin.*'], - scope: 'global', - }, - ], - }, - ], - }, -] diff --git a/src/layouts/config.jsx b/src/layouts/config.jsx new file mode 100644 index 000000000000..b6872c326f5d --- /dev/null +++ b/src/layouts/config.jsx @@ -0,0 +1,1307 @@ +import { SvgIcon } from '@mui/material' +import { CippIcons } from '../utils/icon-registry' + +export const nativeMenuItems = [ + { + title: 'Dashboard', + path: '/', + icon: ( + + + + ), + permissions: ['CIPP.Core.*'], + }, + { + title: 'Identity Management', + type: 'header', + icon: ( + + + + ), + permissions: ['Identity.*'], + items: [ + { + title: 'Administration', + permissions: ['Identity.User.*'], + items: [ + { + title: 'Users', + path: '/identity/administration/users', + permissions: ['Identity.User.*'], + }, + { + title: 'Guest Users', + path: '/identity/administration/guest-users', + permissions: ['Identity.User.*'], + }, + { + title: 'Risky Users', + path: '/identity/administration/risky-users', + permissions: ['Identity.User.*'], + }, + { + title: 'Groups', + path: '/identity/administration/groups', + permissions: ['Identity.Group.*'], + }, + { + title: 'Group Templates', + path: '/identity/administration/group-templates', + permissions: ['Identity.Group.*'], + scope: 'global', + }, + { + title: 'Devices', + path: '/identity/administration/devices', + permissions: ['Identity.Device.*'], + }, + { + title: 'Deleted Items', + path: '/identity/administration/deleted-items', + permissions: ['Identity.User.*'], + }, + { + title: 'Roles & PIM', + path: '/identity/administration/roles', + permissions: ['Identity.Role.*'], + }, + { + title: 'JIT Admin', + path: '/identity/administration/jit-admin', + permissions: ['Identity.Role.*'], + }, + { + title: 'JIT Admin Templates', + path: '/identity/administration/jit-admin-templates', + permissions: ['Identity.Role.*'], + scope: 'global', + }, + { + title: 'JIT Role Templates', + path: '/identity/administration/jit-role-templates', + permissions: ['Identity.Role.*'], + scope: 'global', + }, + { + title: 'Vacation Mode', + path: '/identity/administration/vacation-mode', + permissions: ['Identity.User.*'], + }, + { + title: 'Offboarding Wizard', + path: '/identity/administration/offboarding-wizard', + permissions: ['Identity.User.*'], + }, + ], + }, + { + title: 'Reports', + permissions: [ + 'Identity.User.*', + 'Identity.Group.*', + 'Identity.Device.*', + 'Identity.Role.*', + 'Identity.AuditLog.*', + ], + items: [ + { + title: 'MFA Report', + path: '/identity/reports/mfa-report', + permissions: ['Identity.User.*'], + }, + { + title: 'Inactive Users', + path: '/identity/reports/inactive-users-report', + permissions: ['Identity.User.*'], + }, + { + title: 'Sign-in Report', + path: '/identity/reports/signin-report', + permissions: ['Identity.User.*'], + }, + { + title: 'Microsoft Entra Connect Report', + path: '/identity/reports/azure-ad-connect-report', + permissions: ['Identity.User.*'], + }, + { + title: 'Risk Detections', + path: '/identity/reports/risk-detections', + permissions: ['Identity.User.*'], + }, + { + title: 'Group Usage Report', + path: '/identity/reports/group-usage', + permissions: ['Identity.Group.*'], + }, + ], + }, + ], + }, + { + title: 'Tenant Administration', + type: 'header', + icon: ( + + + + ), + permissions: [ + 'Tenant.*', + 'Identity.AuditLog.*', + 'CIPP.Backup.*', + 'Scheduler.Billing.*', + ], + items: [ + { + title: 'Administration', + permissions: ['Tenant.Administration.*'], + items: [ + { + title: 'Tenants', + path: '/tenant/administration/tenants', + permissions: ['Tenant.Administration.*'], + scope: 'global', + }, + { + title: 'Alert Configuration', + path: '/tenant/administration/alert-configuration', + permissions: ['Tenant.Alert.*'], + scope: 'global', + }, + { + title: 'Audit Logs', + path: '/tenant/administration/audit-logs', + permissions: ['Identity.AuditLog.*'], + }, + { + title: 'Applications', + path: '/tenant/administration/applications/enterprise-apps', + permissions: ['Tenant.Application.*'], + }, + { + title: 'Secure Score', + path: '/tenant/administration/securescore', + permissions: ['Tenant.Administration.*'], + }, + { + title: 'App Consent Requests', + path: '/tenant/administration/app-consent-requests', + permissions: ['Tenant.Application.*'], + }, + { + title: 'Authentication Methods', + path: '/tenant/administration/authentication-methods', + permissions: ['Tenant.Config.*'], + }, + { + title: 'Partner Relationships', + path: '/tenant/administration/partner-relationships', + permissions: ['Tenant.Relationship.*'], + }, + { + title: 'Domains', + path: '/tenant/administration/domains', + permissions: ['Tenant.Administration.*'], + }, + ], + }, + { + title: 'GDAP Management', + path: '/tenant/gdap-management', + permissions: ['Tenant.Relationship.*'], + scope: 'global', + }, + // Flag-gated swap: the Baselines feature flag lists this path in its Pages + // (hidden while the flag is off) and the classic Standards/Drift paths in + // HidesPages (hidden while it is on) - the two never show together. + { + title: 'Baselines', + path: '/tenant/baselines', + permissions: ['Tenant.Baselines.*'], + scope: 'global', + }, + { + title: 'Domains Analyser', + path: '/tenant/standards/domains-analyser', + permissions: ['Tenant.DomainAnalyser.*'], + scope: 'global', + }, + { + title: 'Standards & Drift', + path: '/tenant/standards/alignment', + permissions: ['Tenant.Standards.*'], + scope: 'global', + }, + { + title: 'Best Practice Analyser', + path: '/tenant/standards/bpa-report', + permissions: ['Tenant.BestPracticeAnalyser.*'], + scope: 'global', + }, + { + title: 'Conditional Access', + permissions: ['Tenant.ConditionalAccess.*'], + items: [ + { + title: 'CA Policies', + path: '/tenant/conditional/list-policies', + permissions: ['Tenant.ConditionalAccess.*'], + }, + { + title: 'CA Templates', + path: '/tenant/conditional/list-template', + permissions: ['Tenant.ConditionalAccess.*'], + scope: 'global', + }, + { + title: 'Named Locations', + path: '/tenant/conditional/list-named-locations', + permissions: ['Tenant.ConditionalAccess.*'], + }, + ], + }, + { + title: 'Reports', + permissions: [ + 'Tenant.Administration.*', + 'Scheduler.Billing.*', + 'Tenant.Application.*', + ], + items: [ + { + title: 'Licence Report', + path: '/tenant/reports/list-licenses', + permissions: ['Tenant.Administration.*'], + }, + { + title: 'Sherweb Licence Report', + path: '/tenant/reports/list-csp-licenses', + permissions: ['Tenant.Directory.*'], + }, + { + title: 'Consented Applications', + path: '/tenant/reports/application-consent', + permissions: ['Tenant.Application.*'], + }, + { + title: 'Graph / Office Reports', + path: '/tenant/reports/graph-office-reports', + permissions: ['Tenant.Reports.*'], + }, + { + title: 'Custom Test Report', + path: '/tenant/reports/custom-test-report', + permissions: ['Tenant.Reports.*'], + }, + ], + }, + { + title: 'Manage Tenant', + path: '/tenant/manage/edit', + permissions: ['Tenant.Administration.*'], + }, + ], + }, + { + title: 'Security & Compliance', + type: 'header', + icon: ( + + + + ), + permissions: [ + 'Security.Incident.*', + 'Security.Alert.*', + 'Tenant.DeviceCompliance.*', + 'Security.SafeLinksPolicy.*', + 'Security.DlpCompliancePolicy.*', + 'Security.RetentionCompliancePolicy.*', + 'Security.SensitivityLabel.*', + 'Security.SensitiveInfoType.*', + ], + items: [ + { + title: 'Incidents & Alerts', + permissions: ['Security.Incident.*'], + items: [ + { + title: 'Incidents', + path: '/security/incidents/list-incidents', + permissions: ['Security.Incident.*'], + }, + { + title: 'Alerts', + path: '/security/incidents/list-alerts', + permissions: ['Security.Alert.*'], + }, + { + title: 'Defender Alerts', + path: '/security/incidents/list-mdo-alerts', + permissions: ['Security.Alert.*'], + }, + { + title: 'Check Alerts', + path: '/security/incidents/list-check-alerts', + permissions: ['Security.Alert.*'], + }, + ], + }, + { + title: 'Defender', + permissions: ['Security.Alert.*'], + items: [ + { + title: 'Defender Status', + path: '/security/defender/list-defender', + permissions: ['Security.Alert.*'], + }, + { + title: 'Defender Deployment', + path: '/security/defender/deployment', + permissions: ['Security.Alert.*'], + }, + { + title: 'Vulnerabilities', + path: '/security/defender/list-defender-tvm', + permissions: ['Security.Alert.*'], + }, + { + title: 'CVE Management', + path: '/security/defender/defender-cve-exceptions', + permissions: ['Security.Alert.*'], + }, + ], + }, + { + title: 'Reports', + permissions: ['Tenant.DeviceCompliance.*', 'Security.Defender.*'], + items: [ + { + title: 'Device Compliance', + path: '/security/reports/list-device-compliance', + permissions: ['Tenant.DeviceCompliance.*'], + }, + { + title: 'MDE Onboarding', + path: '/security/reports/mde-onboarding', + permissions: ['Security.Defender.*'], + }, + { + title: 'Vulnerability Report', + path: '/security/reports/cve-report', + permissions: ['Security.Defender.*'], + }, + ], + }, + { + title: 'Safe Links', + permissions: ['Security.SafeLinksPolicy.*'], + items: [ + { + title: 'Safe Links Policies', + path: '/security/safelinks/safelinks', + permissions: ['Security.SafeLinksPolicy.*'], + }, + { + title: 'Safe Links Templates', + path: '/security/safelinks/safelinks-template', + permissions: ['Security.SafeLinksPolicy.*'], + scope: 'global', + }, + ], + }, + { + title: 'Purview Compliance', + permissions: [ + 'Security.DlpCompliancePolicy.*', + 'Security.RetentionCompliancePolicy.*', + 'Security.SensitivityLabel.*', + 'Security.SensitiveInfoType.*', + ], + items: [ + { + title: 'DLP Policies', + path: '/security/compliance/dlp', + permissions: ['Security.DlpCompliancePolicy.*'], + }, + { + title: 'DLP Policy Templates', + path: '/security/compliance/dlp-templates', + permissions: ['Security.DlpCompliancePolicy.*'], + scope: 'global', + }, + { + title: 'Retention Policies', + path: '/security/compliance/retention', + permissions: ['Security.RetentionCompliancePolicy.*'], + }, + { + title: 'Retention Policy Templates', + path: '/security/compliance/retention-templates', + permissions: ['Security.RetentionCompliancePolicy.*'], + scope: 'global', + }, + { + title: 'Sensitivity Labels', + path: '/security/compliance/labels', + permissions: ['Security.SensitivityLabel.*'], + }, + { + title: 'Sensitivity Label Templates', + path: '/security/compliance/labels-templates', + permissions: ['Security.SensitivityLabel.*'], + scope: 'global', + }, + { + title: 'Sensitive Information Types', + path: '/security/compliance/sit', + permissions: ['Security.SensitiveInfoType.*'], + }, + { + title: 'Sensitive Info Type Templates', + path: '/security/compliance/sit-templates', + permissions: ['Security.SensitiveInfoType.*'], + scope: 'global', + }, + ], + }, + ], + }, + { + title: 'Copilot & AI', + type: 'header', + icon: ( + + + + ), + permissions: ['Tenant.Standards.*'], + items: [ + { + title: 'Shadow AI Discovery', + path: '/copilot/shadow-ai', + permissions: ['Tenant.Standards.*'], + }, + { + title: 'Copilot Settings', + path: '/copilot/settings', + permissions: ['Tenant.Standards.*'], + }, + { + title: 'Agent365', + permissions: ['Tenant.Standards.*'], + items: [ + { + title: 'Packages', + path: '/copilot/agent365/packages', + permissions: ['Tenant.Standards.*'], + }, + ], + }, + { + title: 'Reports', + permissions: ['Tenant.Standards.*'], + items: [ + { + title: 'Copilot Adoption', + path: '/copilot/reports/copilot-adoption', + permissions: ['Tenant.Standards.*'], + }, + { + title: 'Copilot Usage Trend', + path: '/copilot/reports/copilot-trend', + permissions: ['Tenant.Standards.*'], + }, + { + title: 'Copilot User Activity', + path: '/copilot/reports/copilot-usage', + permissions: ['Tenant.Standards.*'], + }, + ], + }, + ], + }, + { + title: 'Intune', + type: 'header', + icon: ( + + + + ), + permissions: [ + 'Endpoint.Application.*', + 'Endpoint.Autopilot.*', + 'Endpoint.MEM.*', + 'Endpoint.Device.*', + 'Endpoint.Device.Read', + ], + items: [ + { + title: 'Applications', + permissions: ['Endpoint.Application.*'], + items: [ + { + title: 'Applications', + path: '/endpoint/applications/list', + permissions: ['Endpoint.Application.*'], + }, + { + title: 'Application Queue', + path: '/endpoint/applications/queue', + permissions: ['Endpoint.Application.*'], + }, + { + title: 'Application Templates', + path: '/endpoint/applications/templates', + docsPath: 'endpoint/applications/application-templates', + permissions: ['Endpoint.Application.*'], + scope: 'global', + }, + ], + }, + { + title: 'Autopilot & Enrollment', + permissions: ['Endpoint.Autopilot.*'], + items: [ + { + title: 'Autopilot Devices', + path: '/endpoint/autopilot/list-devices', + permissions: ['Endpoint.Autopilot.*'], + }, + { + title: 'Add Autopilot Device', + path: '/endpoint/autopilot/add-device', + permissions: ['Endpoint.Autopilot.*'], + }, + { + title: 'Enrollment Profiles', + path: '/endpoint/autopilot/enrollment-profiles', + permissions: ['Endpoint.Autopilot.*'], + }, + { + title: 'Status Pages', + path: '/endpoint/autopilot/list-status-pages', + permissions: ['Endpoint.Autopilot.*'], + }, + ], + }, + { + title: 'Device Management', + permissions: ['Endpoint.MEM.*', 'Endpoint.Device.*'], + items: [ + { + title: 'Devices', + path: '/endpoint/MEM/devices', + docsPath: 'endpoint/mem/devices', + permissions: ['Endpoint.Device.*'], + }, + { + title: 'BitLocker Key Search', + path: '/endpoint/MEM/bitlocker-search', + docsPath: 'endpoint/mem/bitlocker-search', + permissions: ['Endpoint.Device.*'], + }, + { + title: 'Configuration Policies', + path: '/endpoint/MEM/list-policies', + docsPath: 'endpoint/mem/list-policies', + permissions: ['Endpoint.MEM.*'], + }, + { + title: 'Compliance Policies', + path: '/endpoint/MEM/list-compliance-policies', + docsPath: 'endpoint/mem/list-compliance-policies', + permissions: ['Endpoint.MEM.*'], + }, + { + title: 'App Policies', + path: '/endpoint/MEM/list-appprotection-policies', + docsPath: 'endpoint/mem/list-appprotection-policies', + permissions: ['Endpoint.MEM.*'], + }, + { + title: 'Policy Templates', + path: '/endpoint/MEM/list-templates', + docsPath: 'endpoint/mem/list-templates', + permissions: ['Endpoint.MEM.*'], + scope: 'global', + }, + { + title: 'Reusable Settings', + path: '/endpoint/MEM/reusable-settings', + docsPath: 'endpoint/mem/reusable-settings', + permissions: ['Endpoint.MEM.*'], + }, + { + title: 'Reusable Settings Templates', + path: '/endpoint/MEM/reusable-settings-templates', + docsPath: 'endpoint/mem/reusable-settings-templates', + permissions: ['Endpoint.MEM.*'], + scope: 'global', + }, + { + title: 'Assignment Filters', + path: '/endpoint/MEM/assignment-filters', + docsPath: 'endpoint/mem/assignment-filters', + permissions: ['Endpoint.MEM.*'], + }, + { + title: 'Assignment Filter Templates', + path: '/endpoint/MEM/assignment-filter-templates', + docsPath: 'endpoint/mem/assignment-filter-templates', + permissions: ['Endpoint.MEM.*'], + scope: 'global', + }, + { + title: 'Scripts', + path: '/endpoint/MEM/list-scripts', + docsPath: 'endpoint/mem/list-scripts', + permissions: ['Endpoint.MEM.*'], + }, + { + title: 'MAA Requests', + path: '/endpoint/MEM/approval-requests', + docsPath: 'endpoint/mem/approval-requests', + permissions: ['Endpoint.MEM.*'], + }, + ], + }, + { + title: 'Reports', + permissions: [ + 'Endpoint.Device.*', + 'Endpoint.Autopilot.*', + 'Endpoint.MEM.*', + ], + items: [ + { + title: 'Analytics Device Score', + path: '/endpoint/reports/analyticsdevicescore', + permissions: ['Endpoint.Device.*'], + }, + { + title: 'Work from Anywhere', + path: '/endpoint/reports/workfromanywhere', + permissions: ['Endpoint.Device.*'], + }, + { + title: 'Autopilot Deployments', + path: '/endpoint/reports/autopilot-deployment', + permissions: ['Endpoint.Autopilot.*'], + }, + { + title: 'Discovered Apps', + path: '/endpoint/reports/detected-apps', + permissions: ['Endpoint.MEM.*'], + }, + ], + }, + ], + }, + { + title: 'Teams & SharePoint', + type: 'header', + icon: ( + + + + ), + permissions: [ + 'Sharepoint.Site.*', + 'Sharepoint.Admin.*', + 'Teams.Group.*', + 'Teams.Activity.*', + 'Teams.Voice.*', + ], + items: [ + { + title: 'OneDrive', + path: '/teams-share/onedrive', + permissions: ['Sharepoint.Site.*'], + }, + { + title: 'SharePoint', + path: '/teams-share/sharepoint', + permissions: ['Sharepoint.Admin.*'], + }, + { + title: 'SharePoint Templates', + path: '/teams-share/sharepoint-templates', + permissions: ['Sharepoint.Admin.*'], + }, + { + title: 'Deleted Sites', + path: '/teams-share/deleted-sites', + permissions: ['Sharepoint.Admin.*'], + }, + { + title: 'Sharing Report', + path: '/teams-share/sharing-report', + permissions: ['Sharepoint.Site.*'], + }, + { + title: 'Permissions Report', + path: '/teams-share/permissions-report', + permissions: ['Sharepoint.Site.*'], + }, + { + title: 'External Users', + path: '/teams-share/external-users', + permissions: ['Sharepoint.Site.*'], + }, + { + title: 'Teams', + permissions: ['Teams.Group.*'], + items: [ + { + title: 'Teams', + path: '/teams-share/teams/list-team', + permissions: ['Teams.Group.*'], + }, + { + title: 'Teams Activity', + path: '/teams-share/teams/teams-activity', + permissions: ['Teams.Activity.*'], + }, + { + title: 'Business Voice', + path: '/teams-share/teams/business-voice', + permissions: ['Teams.Voice.*'], + }, + ], + }, + ], + }, + { + title: 'Email & Exchange', + type: 'header', + icon: ( + + + + ), + permissions: [ + 'Exchange.Mailbox.*', + 'Exchange.Contact.*', + 'Exchange.SpamFilter.*', + 'Exchange.TransportRule.*', + 'Exchange.Connector.*', + 'Exchange.ConnectionFilter.*', + 'Exchange.Equipment.*', + 'Exchange.Room.*', + 'Exchange.SafeLinks.*', + 'Exchange.Group.*', + 'Exchange.RetentionPolicies.*', + ], + items: [ + { + title: 'Administration', + permissions: ['Exchange.Mailbox.*'], + items: [ + { + title: 'Mailboxes', + path: '/email/administration/mailboxes', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'HVE Accounts', + path: '/email/administration/hve-accounts', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Deleted Mailboxes', + path: '/email/administration/deleted-mailboxes', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Mailbox Rules', + path: '/email/administration/mailbox-rules', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Contacts', + path: '/email/administration/contacts', + permissions: ['Exchange.Contact.*'], + }, + { + title: 'Contact Templates', + path: '/email/administration/contacts-template', + permissions: ['Exchange.Contact.*'], + scope: 'global', + }, + { + title: 'Quarantine', + path: '/email/administration/quarantine', + permissions: ['Exchange.SpamFilter.*'], + }, + { + title: 'Restricted Users', + path: '/email/administration/restricted-users', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Tenant Allow/Block Lists', + path: '/email/administration/tenant-allow-block-lists', + permissions: ['Exchange.SpamFilter.*'], + }, + { + title: 'Allow/Block List Templates', + path: '/email/administration/tenant-allow-block-list-templates', + permissions: ['Exchange.SpamFilter.*'], + scope: 'global', + }, + { + title: 'Retention Policies & Tags', + path: '/email/administration/exchange-retention/policies', + permissions: ['Exchange.RetentionPolicies.*'], + }, + ], + }, + { + title: 'Transport', + permissions: ['Exchange.TransportRule.*', 'Exchange.Connector.*'], + items: [ + { + title: 'Transport Rules', + path: '/email/transport/list-rules', + permissions: ['Exchange.TransportRule.*'], + }, + { + title: 'Transport Templates', + path: '/email/transport/list-templates', + permissions: ['Exchange.TransportRule.*'], + scope: 'global', + }, + { + title: 'Connectors', + path: '/email/transport/list-connectors', + permissions: ['Exchange.Connector.*'], + }, + { + title: 'Connector Templates', + path: '/email/transport/list-connector-templates', + permissions: ['Exchange.Connector.*'], + scope: 'global', + }, + ], + }, + { + title: 'Spamfilter', + permissions: ['Exchange.SpamFilter.*', 'Exchange.ConnectionFilter.*'], + items: [ + { + title: 'Spamfilter', + path: '/email/spamfilter/list-spamfilter', + permissions: ['Exchange.SpamFilter.*'], + }, + { + title: 'Spamfilter Templates', + path: '/email/spamfilter/list-templates', + permissions: ['Exchange.SpamFilter.*'], + scope: 'global', + }, + { + title: 'Connection Filter', + path: '/email/spamfilter/list-connectionfilter', + permissions: ['Exchange.ConnectionFilter.*'], + }, + { + title: 'Connection Filter Templates', + path: '/email/spamfilter/list-connectionfilter-templates', + permissions: ['Exchange.ConnectionFilter.*'], + scope: 'global', + }, + { + title: 'Quarantine Policies', + path: '/email/spamfilter/list-quarantine-policies', + permissions: ['Exchange.SpamFilter.*'], + }, + ], + }, + { + title: 'Resource Management', + permissions: ['Exchange.Equipment.*', 'Exchange.Room.*'], + items: [ + { + title: 'Equipment', + path: '/email/resources/management/equipment', + permissions: ['Exchange.Equipment.*'], + }, + { + title: 'Rooms', + path: '/email/resources/management/list-rooms', + permissions: ['Exchange.Room.*'], + }, + { + title: 'Room Lists', + path: '/email/resources/management/room-lists', + permissions: ['Exchange.Room.*'], + }, + ], + }, + { + title: 'Reports', + permissions: [ + 'Exchange.Mailbox.*', + 'Exchange.SpamFilter.*', + 'Exchange.SafeLinks.*', + 'Exchange.Group.*', + ], + items: [ + { + title: 'Mail Flow Statistics', + path: '/email/reports/mail-flow-statistics', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Mailbox Statistics', + path: '/email/reports/mailbox-statistics', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Mailbox Activity', + path: '/email/reports/mailbox-activity', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Mailbox Client Access Settings', + path: '/email/reports/mailbox-cas-settings', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Mailbox Permissions', + path: '/email/reports/mailbox-permissions', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Calendar Permissions', + path: '/email/reports/calendar-permissions', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Mailbox Forwarding', + path: '/email/reports/mailbox-forwarding', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Anti-Phishing Filters', + path: '/email/reports/antiphishing-filters', + permissions: ['Exchange.SpamFilter.*'], + }, + { + title: 'Malware Filters', + path: '/email/reports/malware-filters', + permissions: ['Exchange.SpamFilter.*'], + }, + { + title: 'Safe Attachments Filters', + path: '/email/reports/safeattachments-filters', + permissions: ['Exchange.SafeLinks.*'], + }, + { + title: 'Shared Mailbox with Enabled Account', + path: '/email/reports/SharedMailboxEnabledAccount', + docsPath: 'email/reports/sharedmailboxenabledaccount', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'ActiveSync Devices', + path: '/email/reports/activesync-devices', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Global Address List', + path: '/email/reports/global-address-list', + permissions: ['Exchange.Group.*'], + }, + ], + }, + ], + }, + { + title: 'Tools', + type: 'header', + icon: ( + + + + ), + permissions: [ + 'CIPP.*', + 'Tenant.Administration.*', + 'Tenant.Application.*', + 'Tenant.DomainAnalyser.*', + 'Exchange.Mailbox.*', + 'Endpoint.MEM.*', + 'CIPP.Scheduler.*', + ], + items: [ + { + title: 'Tenant Tools', + permissions: ['Tenant.Administration.*'], + items: [ + { + title: 'Graph Explorer', + path: '/tenant/tools/graph-explorer', + docsPath: 'tools/tenant-tools/graph-explorer', + permissions: ['Tenant.Administration.*'], + }, + { + title: 'Tenant Lookup', + path: '/tenant/tools/tenantlookup', + docsPath: 'tools/tenant-tools/tenantlookup', + permissions: ['Tenant.Administration.*'], + scope: 'global', + }, + { + title: 'Application Approval', + path: '/tenant/tools/appapproval', + docsPath: 'tools/tenant-tools/appapproval', + permissions: ['Tenant.Application.*'], + }, + { + title: 'Individual Domain Check', + path: '/tenant/tools/individual-domains', + docsPath: 'tools/tenant-tools/individual-domains', + permissions: ['Tenant.DomainAnalyser.*'], + scope: 'global', + }, + { + title: 'IP Database', + path: '/tenant/tools/geoiplookup', + docsPath: 'tools/tenant-tools/geoiplookup', + permissions: ['CIPP.Core.*'], + scope: 'global', + }, + ], + }, + { + title: 'Email Tools', + permissions: ['Exchange.Mailbox.*'], + items: [ + { + title: 'Message Trace', + path: '/email/tools/message-trace', + docsPath: 'tools/email-tools/message-trace', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Message Viewer', + path: '/email/tools/message-viewer', + docsPath: 'tools/email-tools/message-viewer', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Mailbox Restores', + path: '/email/tools/mailbox-restores', + docsPath: 'tools/email-tools/mailbox-restores', + permissions: ['Exchange.Mailbox.*'], + }, + { + title: 'Message Encryption', + path: '/email/tools/message-encryption', + docsPath: 'tools/email-tools/message-encryption', + permissions: ['Exchange.Mailbox.*'], + }, + ], + }, + { + title: 'Intune Tools', + permissions: ['Endpoint.MEM.*'], + items: [ + { + title: 'Compare Policies', + path: '/endpoint/MEM/compare-policies', + docsPath: 'tools/intune-tools/compare-policies', + permissions: ['Endpoint.MEM.*'], + scope: 'global', + }, + ], + }, + { + title: 'Dark Web Tools', + permissions: ['CIPP.Core.*'], + items: [ + { + title: 'Tenant Breach Lookup', + path: '/tools/tenantbreachlookup', + docsPath: 'tools/dark-web-tools/tenant-breach-lookup', + permissions: ['CIPP.Core.*'], + scope: 'global', + }, + { + title: 'Breach Lookup', + path: '/tools/breachlookup', + docsPath: 'tools/dark-web-tools/breach-lookup', + permissions: ['CIPP.Core.*'], + scope: 'global', + }, + ], + }, + { + title: 'Report Builder', + path: '/tools/report-builder/generated', + roles: ['admin', 'superadmin'], + permissions: ['CIPP.Core.*'], + scope: 'global', + }, + { + title: 'Custom Tests', + path: '/tools/custom-tests', + roles: ['admin', 'superadmin'], + permissions: ['CIPP.Tests.*'], + scope: 'global', + }, + { + title: 'Template Library', + path: '/tools/templatelib', + roles: ['editor', 'admin', 'superadmin'], + permissions: ['CIPP.Core.*'], + scope: 'global', + }, + { + title: 'Catalog', + path: '/tools/community-repos', + roles: ['editor', 'admin', 'superadmin'], + permissions: ['CIPP.Core.*'], + scope: 'global', + }, + { + title: 'Template Package Manager', + path: '/tools/template-packages', + roles: ['editor', 'admin', 'superadmin'], + permissions: ['Tenant.ConditionalAccess.*', 'Endpoint.MEM.*'], + scope: 'global', + }, + { + title: 'Scheduler', + path: '/cipp/scheduler', + docsPath: 'tools/scheduler', + roles: ['editor', 'admin', 'superadmin'], + permissions: ['CIPP.Scheduler.*'], + }, + ], + }, + { + title: 'CIPP', + type: 'header', + icon: ( + + + + ), + permissions: [ + 'CIPP.*', // Pattern matching - matches any CIPP permission + ], + items: [ + { + title: 'Application Settings', + path: '/cipp/settings', + roles: ['admin', 'superadmin'], + permissions: ['CIPP.AppSettings.*'], + scope: 'global', + }, + { + title: 'Logbook', + path: '/cipp/logs', + roles: ['editor', 'admin', 'superadmin'], + permissions: ['CIPP.Core.*'], + scope: 'global', + }, + { + title: 'Setup Wizard', + path: '/onboardingv2', + roles: ['admin', 'superadmin'], + permissions: ['CIPP.AppSettings.*'], + scope: 'global', + }, + { + title: 'Integrations', + path: '/cipp/integrations', + roles: ['admin', 'superadmin'], + permissions: ['CIPP.Extension.*'], + scope: 'global', + }, + { + title: 'Custom Data', + path: '/cipp/custom-data/directory-extensions', + roles: ['admin', 'superadmin'], + permissions: ['CIPP.AppSettings.*'], + scope: 'global', + }, + { + title: 'Advanced', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + items: [ + { + title: 'Super Admin', + path: '/cipp/advanced/super-admin/tenant-mode', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + { + title: 'Container Management', + path: '/cipp/advanced/container-management/status', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + { + // Lands on cipp-roles, not cipp-users: cipp-users is gated by the + // SuperAdminNG feature flag, and the nav filter drops any item whose + // path is in a disabled flag's Pages list — pointing here at cipp-users + // would hide the whole Authentication group (including the ungated + // SSO and SAM App pages) on non-NG instances. + title: 'Authentication', + path: '/cipp/advanced/authentication/cipp-roles', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + { + title: 'Exchange Cmdlets', + path: '/cipp/advanced/exchange-cmdlets', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + { + title: 'Timers', + path: '/cipp/advanced/timers', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + { + title: 'Table Maintenance', + path: '/cipp/advanced/table-maintenance', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + { + title: 'CIPPDB Cache', + path: '/cipp/advanced/cippdb-cache', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + { + title: 'Diagnostics', + path: '/cipp/advanced/diagnostics', + roles: ['superadmin'], + permissions: ['CIPP.SuperAdmin.*'], + scope: 'global', + }, + ], + }, + ], + }, +] diff --git a/src/layouts/constants.js b/src/layouts/constants.js index 37c513ad06cc..3a9591676300 100644 --- a/src/layouts/constants.js +++ b/src/layouts/constants.js @@ -13,3 +13,15 @@ export const SIDE_NAV_COLLAPSED_WIDTH = 73 // icon size + padding + border right // property on :root so the fixed chrome can offset itself without prop threading. Resolves to 0px // whenever no banner is mounted, which is the overwhelmingly common case. export const BANNER_HEIGHT_VAR = 'var(--cipp-banner-h, 0px)' + +// Notch / status-bar inset. Requires viewport-fit=cover on the meta viewport. When a banner is +// showing it already pads itself with this inset and publishes that in --cipp-banner-h, so chrome +// below the banner must not add it again — see SAFE_AREA_TOP_OFFSET. +export const SAFE_AREA_TOP_VAR = 'env(safe-area-inset-top, 0px)' + +// Safe-area to apply above the top nav only when no banner is eating the top of the screen. +// max() keeps a single expression working for both "banner on" and "banner off". +export const SAFE_AREA_TOP_OFFSET = `max(0px, calc(${SAFE_AREA_TOP_VAR} - ${BANNER_HEIGHT_VAR}))` + +// Fixed chrome below the status bar / banner: top nav + residual safe-area + banner height. +export const CHROME_TOP_OFFSET = `calc(${TOP_NAV_HEIGHT}px + ${BANNER_HEIGHT_VAR} + ${SAFE_AREA_TOP_OFFSET})` diff --git a/src/layouts/footer.js b/src/layouts/footer.jsx similarity index 100% rename from src/layouts/footer.js rename to src/layouts/footer.jsx diff --git a/src/layouts/index.js b/src/layouts/index.js deleted file mode 100644 index db2db462a98a..000000000000 --- a/src/layouts/index.js +++ /dev/null @@ -1,379 +0,0 @@ -import { useCallback, useEffect, useMemo, useState, useRef } from 'react' -import { usePathname } from 'next/navigation' -import { Box, Container, Divider, Stack } from '@mui/material' -import { styled } from '@mui/material/styles' -import { useIsMobileLayout } from '../hooks/use-breakpoint' -import { useSettings } from '../hooks/use-settings' -import { Footer } from './footer' -import { MobileNav } from './mobile-nav' -import { SideNav } from './side-nav' -import { TopNav } from './top-nav' -import { ApiGetCall } from '../api/ApiCall' -import { useDispatch } from 'react-redux' -import { showToast } from '../store/toasts' -import Grid from '@mui/system/Grid' -import { CippImageCard } from '../components/CippCards/CippImageCard' -import { nativeMenuItems } from './config' -import { CippBreadcrumbNav } from '../components/CippComponents/CippBreadcrumbNav' -import { SsoMigrationDialog } from '../components/CippComponents/SsoMigrationDialog' -import { ForcedSsoMigrationDialog } from '../components/CippComponents/ForcedSsoMigrationDialog' -import { SubscriptionEndedDialog } from '../components/CippComponents/SubscriptionEndedDialog' -import { FailedPaymentDialog } from '../components/CippComponents/FailedPaymentDialog' -import { CippMaintenanceBanner } from '../components/CippComponents/CippMaintenanceBanner' -import { CippImpersonationBanner } from '../components/CippComponents/CippImpersonationBanner' - -import { - BANNER_HEIGHT_VAR, - SIDE_NAV_COLLAPSED_WIDTH, - SIDE_NAV_WIDTH, - TOP_NAV_HEIGHT, -} from './constants' - -const useMobileNav = () => { - const pathname = usePathname() - const [open, setOpen] = useState(false) - - const handlePathnameChange = useCallback(() => { - if (open) { - setOpen(false) - } - }, [open]) - - useEffect(() => { - handlePathnameChange() - }, [pathname]) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) - - const handleClose = useCallback(() => { - setOpen(false) - }, []) - - return { - handleClose, - handleOpen, - open, - } -} - -// No breakpoint paddingLeft here: the side-nav offset is applied once, via the inline -// `sx` on the rendered LayoutRoot (it depends on pinNav). A second static rule at lg+ -// used to fight that dynamic one over the same property. -const LayoutRoot = styled('div')(({ theme }) => ({ - backgroundColor: theme.palette.background.default, - display: 'flex', - flex: '1 1 auto', - maxWidth: '100%', - height: '100vh', - overflow: 'hidden', - paddingTop: `calc(${TOP_NAV_HEIGHT}px + ${BANNER_HEIGHT_VAR})`, -})) - -const LayoutContainer = styled('div')({ - display: 'flex', - flex: '1 1 auto', - flexDirection: 'column', - width: '100%', - overflowY: 'auto', - overscrollBehavior: 'contain', -}) - -export const Layout = (props) => { - // showBreadcrumb: the error routes opt out — there is no trail to a page that - // doesn't exist or just crashed, and the bookmark button lives in there too. - const { children, allTenantsSupport = true, showBreadcrumb = true } = props - // one gate for the swap: drawer, the hamburger that opens it (top-nav), the gutter below - const navCollapsed = useIsMobileLayout() - const settings = useSettings() - const mobileNav = useMobileNav() - const [fetchingVisible, setFetchingVisible] = useState([]) - const [menuItems, setMenuItems] = useState(nativeMenuItems) - const lastUserSettingsUpdate = useRef(null) - const currentTenant = settings?.currentTenant - const [hideSidebar, setHideSidebar] = useState(false) - - const swaStatus = ApiGetCall({ - url: '/.auth/me', - queryKey: 'authmeswa', - staleTime: 120000, - refetchOnWindowFocus: true, - }) - - const currentRole = ApiGetCall({ - url: '/api/me', - queryKey: 'authmecipp', - waiting: swaStatus.isSuccess && swaStatus.data?.clientPrincipal !== null, - }) - - const featureFlags = ApiGetCall({ - url: '/api/ListFeatureFlags', - queryKey: 'featureFlags', - staleTime: 600000, // Cache for 10 minutes - }) - - useEffect(() => { - if (currentRole.isSuccess && !currentRole.isFetching) { - const userRoles = currentRole.data?.clientPrincipal?.userRoles - const userPermissions = currentRole.data?.permissions - if (!userRoles) { - setMenuItems([]) - setHideSidebar(true) - return - } - - // Get hidden pages from feature flags - only filter if we have valid data. - // A DISABLED flag hides its Pages (features gated behind the flag); an ENABLED - // flag hides its HidesPages (features it replaces - e.g. Baselines supersedes - // the classic Standards and Drift pages). - let hiddenPages = [] - if (featureFlags.isSuccess && Array.isArray(featureFlags.data)) { - const disabledPages = featureFlags.data - .filter((flag) => flag.Enabled === false || flag.enabled === false) - .flatMap((flag) => flag.Pages || flag.pages || []) - .filter((page) => typeof page === 'string') - const replacedPages = featureFlags.data - .filter((flag) => flag.Enabled === true || flag.enabled === true) - .flatMap((flag) => flag.HidesPages || flag.hidesPages || []) - .filter((page) => typeof page === 'string') - hiddenPages = [...disabledPages, ...replacedPages] - } - - const filterItemsByRole = (items) => { - return items - .map((item) => { - // Check if page is hidden by feature flag - if (item.path && hiddenPages.length > 0 && hiddenPages.includes(item.path)) { - return null - } - - // Check permission with pattern matching support - if (item.permissions && item.permissions.length > 0) { - const hasPermission = userPermissions?.some((userPerm) => { - return item.permissions.some((requiredPerm) => { - // Exact match - if (userPerm === requiredPerm) { - return true - } - - // Pattern matching - check if required permission contains wildcards - if (requiredPerm.includes('*')) { - // Convert wildcard pattern to regex - const regexPattern = requiredPerm - .replace(/\./g, '\\.') // Escape dots - .replace(/\*/g, '.*') // Convert * to .* - const regex = new RegExp(`^${regexPattern}$`) - return regex.test(userPerm) - } - - return false - }) - }) - if (!hasPermission) { - return null - } - } else { - return null - } - // check sub-items - if (item.items && item.items.length > 0) { - const filteredSubItems = filterItemsByRole(item.items).filter(Boolean) - if (filteredSubItems.length === 0) return null - return { ...item, items: filteredSubItems } - } - - return item - }) - .filter(Boolean) - } - const filteredMenu = filterItemsByRole(nativeMenuItems) - setMenuItems(filteredMenu) - } else if ( - swaStatus.isLoading || - swaStatus.data?.clientPrincipal === null || - swaStatus.data === undefined || - currentRole.isLoading - ) { - setHideSidebar(true) - } - }, [ - currentRole.isSuccess, - swaStatus.data, - swaStatus.isLoading, - currentRole.data?.clientPrincipal?.userRoles, - currentRole.data?.permissions, - currentRole.isFetching, - featureFlags.isSuccess, - featureFlags.data, - ]) - - const handleNavPin = useCallback(() => { - settings.handleUpdate({ - pinNav: !settings.pinNav, - }) - }, [settings]) - - // Unpinned content offset must match the collapsed drawer's real width — the old 50px - // constant left 23px of content underneath the 73px rail. - const offset = settings.pinNav ? SIDE_NAV_WIDTH : SIDE_NAV_COLLAPSED_WIDTH - - const userSettingsAPI = ApiGetCall({ - url: '/api/ListUserSettings', - queryKey: 'userSettings', - }) - - useEffect(() => { - if (userSettingsAPI.isSuccess && !userSettingsAPI.isFetching) { - // Only update if the data has actually changed (using dataUpdatedAt as a proxy) - const dataUpdatedAt = userSettingsAPI.dataUpdatedAt - if (dataUpdatedAt && dataUpdatedAt !== lastUserSettingsUpdate.current) { - const { bookmarks: _bookmarks, ...serverSettings } = userSettingsAPI.data || {} - //if userSettingsAPI.data contains offboardingDefaults.user, delete that specific key. - if (serverSettings.offboardingDefaults?.user) { - delete serverSettings.offboardingDefaults.user - } - if (serverSettings.offboardingDefaults?.keepCopy) { - delete serverSettings.offboardingDefaults.keepCopy - } - if (serverSettings?.currentTheme) { - delete serverSettings.currentTheme - } - // get current devtools settings (device-local only) - var showDevtools = settings.showDevtools - - settings.handleUpdate({ - ...serverSettings, - showDevtools, - }) - - // Track this update and set completion status - lastUserSettingsUpdate.current = dataUpdatedAt - } - } - }, [ - userSettingsAPI.isSuccess, - userSettingsAPI.data, - userSettingsAPI.isFetching, - userSettingsAPI.dataUpdatedAt, - ]) - - const version = ApiGetCall({ - url: '/version.json', - queryKey: 'LocalVersion', - }) - - const alertsAPI = ApiGetCall({ - url: `/api/GetCippAlerts?localversion=${encodeURIComponent(version?.data?.version)}`, - queryKey: 'alertsDashboard', - waiting: false, - refetchOnMount: false, - refetchOnReconnect: false, - keepPreviousData: true, - }) - - // Hosted maintenance notice, if the instance has one set. Derived from the alerts already - // fetched above rather than a second request. - const maintenanceAlert = useMemo( - () => alertsAPI.data?.find((alert) => alert.maintenance === true) ?? null, - [alertsAPI.data] - ) - - useEffect(() => { - if (!hideSidebar && version.isFetched && !alertsAPI.isFetched) { - alertsAPI.waiting = true - alertsAPI.refetch() - } - }, [version, alertsAPI, hideSidebar]) - - useEffect(() => { - if (alertsAPI.isSuccess && !alertsAPI.isFetching) { - setFetchingVisible(new Array(alertsAPI.data.length).fill(true)) - } - }, [alertsAPI.isSuccess, alertsAPI.data, alertsAPI.isFetching]) - const dispatch = useDispatch() - useEffect(() => { - if (alertsAPI.isSuccess && !alertsAPI.isFetching) { - if (alertsAPI.data.length > 0) { - // The maintenance notice renders as its own banner - toasting it too would surface the - // same message three times per page load (banner, snackbar, notification bell). - alertsAPI.data - .filter((alert) => !alert.maintenance) - .forEach((alert) => { - dispatch( - showToast({ - message: alert.Alert, - title: alert.title, - toastError: alert, - }) - ) - }) - } - } - }, [alertsAPI.isSuccess]) - - return ( - <> - {/* Rendered outside the hideSidebar check - maintenance applies to chrome-less pages too. */} - - - {hideSidebar === false && ( - <> - - {navCollapsed && ( - - )} - {!navCollapsed && } - - )} - - - - - - - {(currentTenant === 'AllTenants' || !currentTenant) && !allTenantsSupport ? ( - - - - - - - - - - - ) : ( - - {/* The nav carries its own rail chrome (gutter + divider) so that when it - renders nothing — a single crumb on a phone — no hairline is left behind. */} - {showBreadcrumb && } - {children} - - )} -